Протокол Broker ↔ exordos-nac-agent#
Источник правды для двух реализаций: брокера (exordos_nac, Python) и агента (exordos_nac_agent, Go).
Golden-фикстуры — docs/protocol/fixtures/*.json; обе стороны проверяются против них в тестах, расхождение ловится тестом, а не ревью.
Версия контракта: v1; брокер принимает диапазон MIN_PROTOCOL_VERSION..PROTOCOL_VERSION (1..1). Четыре POST-эндпоинта передают protocol в теле. Два версионируемых GET используют X-NAC-Protocol; GET /v1/device/update/ не проверяет версию. Неподдерживаемая версия возвращает protocol_unsupported (§0a).
Транспорт: HTTP/1.1 + JSON поверх TLS. Все эндпоинты, кроме POST /v1/enroll/, используют mTLS.
Эндпоинты: POST /v1/enroll/, POST /v1/renew/, POST /v1/session/prove/, GET /v1/device/self/, GET /v1/dataplane/state/, POST /v1/dataplane/bindings/, GET /v1/device/update/.
0. Правила, которые нельзя нарушать#
device_uuidберётся только из CN проверенного клиентского сертификата, никогда из тела запроса. Тело, содержащееdevice_uuidна mTLS-эндпоинте, — ошибка400 device_uuid_not_allowed(иначе confused deputy: устройство A действует от имени B).key_protection_claimed— заявление агента, не факт. Брокер обязан хранить его отдельно отkey_protection_verifiedи никогда не копировать одно в другое. Вverifiedпопадает только то, что сервер проверил сам (§7); устройство, не приславшее доказательств, остаётся наsoftware.- Доказательства аттестации опциональны. В запросе регистрации есть
attestation, в запросе подтверждения —attestation_responseиboot_attestation. Если данных нет, передавайтеnull, не опускайте эти поля (§1, §4a, §7). - Форматы криптоматериала: всё PEM, ключи ECDSA P-256 (единственная кривая, общая для Secure Enclave, TPM 2.0 и Windows PCP). Хэши —
sha256:<hex>. - У устройства один NAC-сертификат. Ключ хранится в настроенном хранилище; аппаратные backend не экспортируют закрытый ключ. NAC не выдаёт VPN- или Wi-Fi-учётные данные: допуск в сеть обеспечивает существующая инфраструктура (границы системы).
- Ошибка — всегда JSON вида
{"error": "<code>", "message": "<human>", "retry_after": <sec|null>}. Код машиночитаемый, из перечня §6. - Агент игнорирует незнакомые поля ответа, поэтому новое поле доходит до необновлённого парка так, будто его нет. Добавить поле в ответ можно, только если агент, отбросивший его, всё равно поступает правильно. Всё, что сужает разрешённое существующим ответом, — отзыв, deny-набор, новое условие гранта — требует новой версии
X-NAC-Protocolили нового эндпоинта: добавленное полем, оно молча игнорируется каждым старым шлюзом, и тот продолжает применять более широкое правило. GET /v1/device/update/не меняется никогда. Через него агент, который больше не может говорить с остальным API, заменяется на тот, что может (§4d), поэтому он принимает только сертификат устройства и?version=, не проверяет версию протокола, не требует политики и отвечает{"agent_update": <оффер|null>}в форме оффера из §4a. Брокер, изменивший хоть что-то из этого, бросает ровно тех агентов, ради которых эндпоинт существует. Поднимать версию контракта безопасно только благодаря этому правилу.
0a. Переход на новую версию контракта#
Брокер и агенты обновляются поэтапно, чтобы шлюзы продолжали получать ответы в понятной им версии протокола:
- Установите
PROTOCOL_VERSION=2, оставивMIN_PROTOCOL_VERSION=1. Брокер должен отвечать в версии каждого запроса. - Раскатите агентов v2 по когортам. Шлюзы enforcement обновляйте вручную: они не входят в канал релизов устройств (обновление шлюзов вручную).
- Проверьте
exordos-nac-broker device versions. Разберите или удалите устройства, давно не выходившие на связь: их последняя версия не подтверждает поддержку пути восстановления. - Устанавливайте
MIN_PROTOCOL_VERSION=2, только если каждое сохраняемое устройство поддерживает неверсионируемыйGET /v1/device/update/(§4d). Более старые агенты не восстановятся после отказа из-за версии.
1. POST /v1/enroll/#
Публичный эндпоинт без IAM-аутентификации. Для регистрации нужен одноразовый enroll_token. Брокер сохраняет владельца для учёта, но не разрешает группы IAM для решений по политике. Политика использует назначенные оператором теги устройства; загрузка отклоняет правила с owner_groups (модель политики).
{
"protocol": 1,
"enroll_token": "1f3c…", // одноразовый, TTL на стороне брокера
"csr": "-----BEGIN CERTIFICATE REQUEST-----\n…",
"hw_fingerprint": {
"smbios_uuid": "4c4c4544-0…", // null, если недоступен
"board_serial": "PF3M2K1S",
"machine_id": "sha256:9ab…", // /etc/machine-id, MachineGuid, IOPlatformUUID
"ek_pub_sha256": null // необязательно: агент может сообщить свой запиненный EK
},
"platform": {
"os": "linux", // linux | windows | darwin
"os_version": "24.04",
"arch": "amd64",
"hostname": "wks-1234",
"agent_version": "0.1.0"
},
"key_protection_claimed": "hardware_bound", // software | hardware_bound
"degradation_reason": null, // строка, если бэкенд деградировал до software
"attestation": null // §7: {"kind": "tpm2", …}; null когда аттестации нет
}
Ответ 201:
{
"device_uuid": "8f14e45f-ce4a-4c1e-9d1b-2a5c9f0e7b31",
"cert": "-----BEGIN CERTIFICATE-----\n…",
"ca_chain": ["-----BEGIN CERTIFICATE-----\n…"],
"renew_after": "2026-07-27T02:00:00Z", // 1/3 остатка TTL
"not_after": "2026-07-27T10:00:00Z",
"credential_challenge": null // §7, null когда устройство не аттестуется
}
Брокер обязан: погасить токен до выдачи серта (повторный enroll тем же токеном → 409 token_already_used); проверить, что CSR подписан заявленным публичным ключом; записать AuditEvent на каждую выдачу.
CSR-требования: ключ EC P-256, подпись ecdsa-with-SHA256, CN игнорируется (брокер назначает свой), SAN в CSR игнорируются. Нарушение → 400 csr_invalid.
2. POST /v1/renew/#
mTLS текущим device cert. Тела с device_uuid не бывает (правило §0.1).
{
"protocol": 1,
"csr": "-----BEGIN CERTIFICATE REQUEST-----\n…",
"hw_fingerprint": { … }, // как в enroll
"key_protection_claimed": "hardware_bound",
"degradation_reason": null
}
Ответ 200 содержит новый сертификат, ca_chain, renew_after и
not_after; в нём нет device_uuid и credential_challenge.
Ключ устройства при renew не меняется: агент шлёт CSR на тот же публичный ключ. Потерянный ключ заменяется повторной регистрацией под новой идентичностью (§3). Брокер сравнивает публичный ключ CSR с ключом предъявленного mTLS-сертификата: расхождение → 400 key_rotation_not_allowed.
Понижение уровня (hardware_bound → software) в renew разрешено и не является отказом: TPM ломается немалициозно (потеря ключа). Брокер обновляет key_protection_claimed, пишет AuditEvent с degradation_reason и выдаёт серт. Решение об урезании доступа принимает Policy Engine, не брокер.
3. Замена потерянного ключа#
Продление требует действующего ключа и сертификата устройства. Если ключ
потерян, сбросьте состояние агента и зарегистрируйте устройство с новым
токеном. Оно получит новый device_uuid; старую запись следует отдельно
заблокировать или удалить. hw_fingerprint служит для диагностики и не
разрешает заменить ключ. См. блокировку и потерю ключа.
4. GET /v1/device/self/#
mTLS, диагностика. 200:
{
"device_uuid": "8f14e45f-…",
"status": "ACTIVE", // ACTIVE|BLOCKED
"key_protection_claimed": "hardware_bound",
"key_protection_verified": "software", // software | attested | device_attested
"cert_not_after": "2026-07-27T10:00:00Z",
"renew_after": "2026-07-27T02:00:00Z"
}
key_protection_verified отдаётся наружу намеренно: агент показывает в status, что сервер не подтвердил аппаратность, — иначе разница между заявленным и проверенным останется незаметной до первого инцидента.
4a. POST /v1/session/prove/#
mTLS с сертификатом устройства — единственным сертификатом, который выпускает NAC. Программное хранилище ключей можно скопировать.
{
"protocol": 1,
"posture_report": {
"collected_at": "2026-08-14T12:00:00Z", // обязателен, если есть signals
"signals": { "disk_encryption": true, "secure_boot": true, "os_version": "24.04" }
},
"attestation_response": { … }, // ответ на выданный челлендж, либо null
"boot_attestation": null // TPM quote, либо null без TPM-доказательств
}
agent_update — релиз, на который переезжает инсталляция; null — обычный
ответ: устройство уже на целевой версии, когорта ещё не открыта, либо раскатки
нет вовсе. Он едет в этом ответе, а не на своём эндпоинте, потому что
доказательство и так происходит каждые несколько минут по соединению,
аутентифицированному девайсовым ключом: отдельный канал был бы вторым, что надо
аутентифицировать, вторым, до чего надо дотянуться из карантина, и вторым, что
может лежать.
Брокер раздаёт, но не авторизует. Подпись — Ed25519 над пейлоадом ниже, сделанная не на брокере ключом релиза, которого у него нет, а агент проверяет её ключом, установленным вместе с агентом. Захваченный брокер может придержать релиз или предложить старый; заставить устройство доверять бинарю, который ключ релиза не подписывал, он не может.
key=value через перевод строки, ровно в этом порядке и с завершающим переводом
строки, а не канонический JSON: две реализации на двух языках должны выдавать
одни и те же байты, а канонизация JSON — ровно то место, где это тихо перестаёт
быть правдой.
Подписывается то, что идентифицирует биты, и никогда то, где они лежат и когда
едут. Доля инсталляции, которой открыт релиз, за раскатку двигается несколько
раз, и загнав её под подпись, мы превратили бы офлайн-ключ в операционный. url
вне подписи по смежной причине: брокер отвечает на каждом адресе из
broker_endpoints, и адрес, на который отправляют устройство, собирается из
того, по которому это устройство только что пришло, — так что подписанный URL
означал бы релиз, который ставится в одной сети и не скачивается в другой. Тем
самым брокер выбирает, откуда придут байты, и никогда — какие байты будут
приняты: агент считает хеш скачанного и выбрасывает всё, что не совпало с
подписанным дайджестом.
Брокер заодно переписывает из отчёта то, что относится к самой машине —
agent_version, platform, os_version, package_format — в блок platform устройства, который
до сих пор заполнялся один раз на энролменте и больше никогда. Это по-прежнему
заявление устройства и по-прежнему информационное поле: политика посты́ры читает
сигнал из отчёта, а не из этой строки. Что это даёт — инвентарь инсталляции,
который не является днём, когда всех поставили; с него начинается любая
поэтапная раскатка агента (exordos-nac-broker device versions).
package_format — это deb или rpm, то есть менеджер пакетов, которым
поставлен агент. Если агент поставлен не из пакета, сигнала нет. Релиз может
перечислять одну платформу по разу на формат, и устройству предлагается
артефакт его формата, а если такого нет — ничего. Устройству, которое формат ни
разу не сообщало, предлагается формат из [nac] default_package_format или
единственный формат, который есть в релизе для его платформы.
200:
{
"session_uuid": "b7c1…",
"address": "10.100.0.7", // наблюдаемый, а не заявленный
"state": "proven",
"grants": ["base", "eng"], // имена resource_set, пустой при отказе
"rule": "engineering-full", // null, если не сматчилось ничего
"reason": "matched engineering-full",
"key_protection_verified": "device_attested",
"posture": "degraded", // вердикт сервера, а не устройства
"posture_failed": ["диск зашифрован"], // словами автора политики
"posture_reason": "failed: диск зашифрован",
"credential_challenge": { … }, // на него отвечает следующее доказательство
"agent_update": { // null, кроме открытой раскатки
"version": "0.4.0",
"os": "linux", "arch": "amd64", "format": "deb",
"url": "https://releases.corp/exordos-nac-agent_0.4.0_amd64.deb",
"sha256": "9f2c…",
"signature": "base64 Ed25519"
},
"lease_expires_at": "2026-08-14T12:15:00Z",
"lease_seconds": 900,
"enforced": true, // отвечает ли кто-то за этот адрес
"enforced_from": ["10.10.0.0/22"], // где парк enforce'ится; пусто, если план не записан
"enforced_to": ["10.0.5.0/24"], // и на что он enforce'ится
"broker_endpoints": ["https://broker.corp"] // все адреса, по которым отвечает этот брокер
}
Ответ говорит, подействует ли он. enforced равен false, когда за адрес,
который наблюдал брокер, не отвечает ни одна зарегистрированная точка
enforcement: доказательство прошло, гранты настоящие, и ни один пакет нигде не
обрабатывается иначе. enforced_from — объединение префиксов этих точек, и
агент держит его, чтобы проверить свой source-адрес до следующего
доказательства: именно так ноутбук с двумя туннелями отказывается доказываться
не через тот, и никто при этом не называет интерфейс. Пусто — это один шлюз на
всё: более узкого плана нет, и проверять устройству не с чем.
Разброс при восстановлении задаётся локально в агенте. Перед первым доказательством после обнаружения брокера или смены сетевой привязки устройство ждёт постоянную задержку, вычисленную из UUID, от нуля до трёх секунд. Так запросы одновременно вернувшихся устройств распределяются во времени. Окно фиксировано в агенте; брокер не настраивает его через протокол.
Запрос не называет сессию. Брокер привязывает ответ к адресу, с которого пришёл запрос, — он его наблюдает, а не узнаёт из тела: брокер живёт внутри приватной сети, значит это тот адрес, который устройству выдала сама сеть. Значение, которое устройство несёт, можно одолжить; адрес, с которого оно говорит, — нет. Повторное доказательство с того же адреса продлевает ту же сессию, а не заводит новую.
Истёкшая аренда закрывает свою сессию. Гранты устройство потеряло в момент
истечения, и строка закрывается, чтобы это было видно: доказательство, пришедшее
позже, начинает новое подключение, а не воскрешает старое. Сессии доказывающегося
устройства закрываются на этом же пути; для машин, которые перестали
доказываться, есть exordos-nac-broker session reap.
Аттестация подтверждается здесь, а не при продлении. Серт живёт шестьдесят дней, поэтому продление — сезонное событие, а вопрос «тот ли это TPM» стоит задавать тогда, когда просят доступ, то есть каждые пятнадцать минут. Ответ на предыдущий челлендж приходит, следующий уходит.
Устройству, которое не смогло ответить, не отказывают. Оно опускается до уровня, который может доказать, а во что это обойдётся — решает политика; отказ отобрал бы доступ из-за очищенного TPM (потеря ключа). Это же относится к ответу на челлендж, которого у брокера уже нет, — ровно с таким возвращается машина после долгого отсутствия.
Отказ — это 200. Пустой grants вместе с правилом, которое так решило, —
это ответ; коды ошибок ниже про запросы, по которым решение вообще не
принималось. «Доказалось и не получило ничего» и «ещё не доказалось» — разные
состояния для того, кто разбирает застрявшую машину.
Устройство присылает сигналы, а что они значат — решает брокер. Поля под
вердикт нет: машина, которая могла бы его прислать, ставила бы оценку себе сама.
Правила лежат в policy-файле (posture.require), поэтому поднять планку — это
изменение политики, а не выкатка агента по всему парку.
Сигнал, который платформа не смогла измерить, отсутствует, а не равен false.
Проверка, которой он нужен, падает: молчание — не пропуск. Отчёт без
collected_at отвергается, а старше posture.max_age_seconds даёт posture
unknown — это другой ответ, чем degraded, и обычно означает сломанный агент, а
не устройство, которое надо чинить.
Что провалилось, возвращается устройству, чтобы его владелец узнал, что
чинить, от собственной машины. Скомпрометированный агент по-прежнему может соврать
про сигнал — это честный предел endpoint-posture, и поэтому posture стоит рядом с
key_protection, который сервер проверяет, а не читает.
4b. GET /v1/dataplane/state/#
mTLS девайсовым сертом, и только для устройства с тегом role:enforcer. Точка
enforcement — обычное зарегистрированное устройство: та же регистрация, тот же
ключ в том же TPM, и тег, который ставит только оператор при выпуске токена
(применение разрешений).
200:
{
"policy_version": "1296929d9a7efdfd", // дайджест скомпилированного ruleset
"ruleset": "table inet nac {\n…", // грузить, когда сменилась версия
"lease_seconds": 900,
"grants": [
{
"address": "10.100.0.7",
"sets": ["base", "eng"], // каждый — granted_<set> в ruleset'е
"lease_expires_at": "2026-08-14T12:15:00Z",
"device_uuid": "8f14e45f-…",
"mac": "52:54:00:aa:bb:cc" // кадр, которому принадлежит адрес, или ""
}
],
"conflicts": [
{ "address": "10.0.0.5", "device_uuids": ["8f14…", "9a2e…"] }
]
}
Pull, а не push. Брокер отвечает на вопросы изнутри приватной сети, а не держит очередь доставки на каждый шлюз.
Условный вопрос — для каденции, которая замечает. Ответ несёт ETag, и точка
enforcement присылает его обратно:
GET /v1/dataplane/state/
If-None-Match: "1755351600000000.4.1296929d9a7efdfd.51ab8e0d.d41d8cd98f00b204"
304 Not Modified
ETag: "1755351603000000.4.1296929d9a7efdfd.51ab8e0d.d41d8cd98f00b204"
304 означает, что ничего из того, что этот шлюз ставит, не изменилось: тела нет,
применять нечего, а элементы, уже стоящие в его ядре, сохраняют свои timeout'ы.
Токен непрозрачен и всегда брокерский — он называет момент по часам брокера,
поэтому шлюз с неверными часами не может спросить про момент, которого не было.
Каждый ответ выдаёт токен заново, включая 304, а токен, который этот брокер не
выдавал, просто получает полный ответ.
Каденции две, и арендой является только одна. Точка enforcement задаёт этот
вопрос каждые несколько секунд, поэтому устройство, только что доказавшееся,
enforce'ится за секунды, а не на следующем пулле по каденции аренды. По каденции
аренды она спрашивает без заголовка: переписывание элементов и есть их
продление, и брокер, вечно отвечающий 304, не должен уметь этому помешать.
Брокер сравнивает то, что шлюз ставит, — гранты, конфликты, диапазоны и версию
ruleset'а, — а не тело целиком. lease_expires_at меняется при каждом
доказательстве и до провода не доезжает, поэтому продление где-то в парке
изменением здесь не является.
Грант — это список resource set'ов, а куда их класть, объявляет ruleset.
Доступ — объединение всех правил политики, под которые подошло устройство, и
каждому набору из sets соответствует множество членства granted_<set> в
ruleset'е из того же ответа. Перечисляются только наборы, которые этот ruleset
объявляет: сессия, решённая при прошлой политике, может назвать исчезнувший, а
enforcer, которому велели наполнить несуществующее множество, роняет всю
транзакцию. Перед записью enforcer проверяет это ещё раз.
Все живые гранты возвращаются каждый раз, изменились они или нет. У элементов множеств стоит timeout, поэтому переписывание и есть продление аренды: переставший опрашивать enforcer даёт доступу истечь, а не замораживает его.
Спорный адрес не enforce'ится ни для кого. Гранты ключуются по адресу, поэтому
два устройства на одном означали бы передачу грантов одной машины другой, а
«побеждает последний» сделал бы это молча. Оба отбрасываются, а конфликт попадает
в conflicts, потому что оператору, ищущему устройство без доступа, больше
смотреть некуда. Развёртывание с NAT перед точкой enforcement нельзя enforce'ить
по устройствам — вот здесь это и вылезает.
Заблокированное устройство исчезает из ответа сразу, а не по истечении аренды, и его сессия по дороге закрывается.
4c. POST /v1/dataplane/bindings/#
mTLS девайсовым сертом, role:enforcer, тот же вызывающий, что и в 4b. То, что
точка enforcement измерила про то, кто отвечает за адрес.
{
"protocol": 1,
"changed": [
{ "address": "10.20.0.7",
"was": "52:54:00:aa:bb:cc", // MAC, к которому был привязан грант
"now": "52:54:00:de:ad:be" } // MAC, который отвечает за адрес сейчас
],
"bound": [
// Пин, который эта точка enforcement измерила сама, — чтобы привязка пережила
// её ядро. Отправляется один раз; привязку, поставленную из `mac` в ответе
// состояния, обратно не шлют.
{ "address": "10.30.0.9", "mac": "52:54:00:11:22:33" }
]
}
200:
{
"closed": [
{ "address": "10.20.0.7", "session_uuid": "3f2a…", "device_uuid": "8f14e45f-…" }
],
"bound": [
{ "address": "10.30.0.9", "mac": "52:54:00:11:22:33", "session_uuid": "9d1c…" }
]
}
Энфорсер измеряет, брокер решает. То же разделение, что и в posture, и по той же причине: сегмент видит только точка enforcement, а что такое сессия — знает только брокер. Сюда приезжает наблюдение, а не вердикт.
Докладывается изменение, отсутствие — никогда. Запись о тихой машине протухает постоянно, и прочитать это как смену хозяина значит отобрать доступ у устройства, которое ничего не делало.
Локально доступ уже отрезан к моменту этого вызова: энфорсер снял элемент и сбросил conntrack, потому что свидетельство на его стороне провода, а круговая задержка — это чужой доступ. Этот эндпоинт нужен, чтобы брокер закрыл сессию и перестал отдавать грант на каждом опросе.
Привязка записывается только в сессию, где её нет, а записанные пины возвращаются в bound — и это не тот же список, что прислали. Не перезаписывать — весь смысл: заменить привязку докладом это ровно тот способ, которым машина, только что забравшая адрес, унаследовала бы то, в чём привязка ей отказывает. Привязку заменяет конец сессии, а это смена хозяина.
Доклад, в котором ничего не изменилось, игнорируется — пустые поля или was,
равный now. Такая форма — дефект энфорсера, и реакция на неё превратила бы его
баг в потерянный доступ здесь.
Что получает скомпрометированный энфорсер — возможность закрывать сессии
внутри своего диапазона enforces и нигде больше; ради этой границы диапазон и
существует. Внутри неё вред — это отказ в обслуживании, которого точка
enforcement всегда могла добиться, просто не enforce'я доверенный ей трафик.
Адрес и MAC разбираются, а не принимаются на слово. Оба попадают в
nftables-транзакцию на каждой другой точке enforcement, поэтому значение, не
являющееся адресом и кадром, отвергается здесь — и ещё раз агентом, прежде чем
дойти до nft.
4d. GET /v1/device/update/#
mTLS сертификатом устройства — и больше ничего: ни проверки X-NAC-Protocol, ни тела, ни политики. ?version= — версия, на которой работает агент; без неё брокер берёт ту, что устройство сообщило последней. 200:
{
"agent_update": { // null, когда этому устройству нечего предложить
"version": "0.4.0",
"os": "linux", "arch": "amd64", "format": "deb",
"url": "https://releases.corp/exordos-nac-agent_0.4.0_amd64.deb",
"sha256": "9f2c…",
"signature": "…" // ключ релиза, подписано не на брокере
}
}
Оффер едет в ответе на доказательство, поэтому всё, что ломает доказательство, забирает и оффер. Брокер, перешедший на версию контракта, которую агент не знает, отвергает доказательство раньше, чем оффер сделан; так же — поле запроса, ставшее обязательным, а ответ, который агент не может разобрать, теряется целиком. Единственный способ заменить агента на работающий пропал бы, и каждая машина ждала бы в карантине, пока её не переустановят руками.
Поэтому этот эндпоинт — единственное, что агент может спросить всегда. Решение — тот же offer_for, что и при доказательстве: когорта, платформа, никакого даунгрейда без подписи как откат, — и оффер тот же подписанный объект, который агент проверяет ключом релиза, чем бы он ни пришёл. Агент спрашивает только когда брокер ответил, а договориться не вышло — protocol_unsupported, любой отказ, не означающий «попробуй позже», ответ, который не разбирается, — и не чаще раза в пятнадцать минут; парк, который доказывается нормально, сюда не ходит.
5. Профиль сертификата устройства#
| Поле | Значение |
|---|---|
| Алгоритм | ECDSA P-256, подпись ecdsa-with-SHA256 |
| TTL | 60 дней |
| Subject | CN=<device_uuid> |
| SAN | URI nac://device/<device_uuid> |
| EKU | clientAuth |
| KU | digitalSignature |
| CRL/OCSP | отсутствуют — отзыв это статус устройства, который брокер читает из своей базы на каждом запросе (блокировка устройства) |
| AKID | идентификатор подписавшего ключа, чтобы проверяющий без файла CA всё равно смог собрать цепочку |
| Issuer | device-CA. Сейчас самоподписанный — exordos-nac-broker init-ca делает корень. Офлайновый корень над ним (удостоверяющий центр устройств) удлиняет цепочку на один сертификат |
key_protection в сертификат не кладётся: это posture-измерение, оно меняется между выпусками, а серт переживает любой конкретный ответ. Источник правды — БД брокера.
Агент обновляется при now >= renew_after (остаётся треть срока, то есть через 40 дней): двадцать дней на ретраи при недоступном брокере, и машина, месяц простоявшая выключенной, возвращается к рабочему серту.
6. Коды ошибок#
| HTTP | Код | Когда |
|---|---|---|
| 400 | csr_invalid |
не P-256, битая подпись, не CSR |
| 400 | device_uuid_not_allowed |
device_uuid в теле mTLS-запроса |
| 400 | key_rotation_not_allowed |
CSR при renew на другой публичный ключ |
| 400 | protocol_unsupported |
неизвестная версия |
| 400 | attestation_invalid |
доказательство, которое брокер не смог проверить — и никогда не молчаливое понижение до неаттестованного уровня |
| 401 | token_invalid |
нет такого enroll-токена / истёк |
| 401 | client_cert_required |
mTLS-эндпоинт без проверенного серта |
| 403 | device_blocked |
статус BLOCKED |
| 403 | not_an_enforcer |
эндпоинты дата-плейна, запрошенные устройством без role:enforcer. Отдельный код потому, что шлюз без тега и заблокированное оператором устройство — разные беды, и одна из них отправляет читателя искать, кто блокировал |
| 404 | device_unknown |
нет такого device_uuid, либо CN серта ни во что не резолвится |
| 404 | protocol_unsupported |
путь, которого нет в контракте |
| 405 | protocol_unsupported |
метод, которого этот путь не обслуживает |
| 409 | token_already_used |
повторный enroll тем же токеном |
| 429 | rate_limited |
+ retry_after |
| 503 | ca_unavailable |
подписывающий сервис недоступен |
| 503 | policy_unavailable |
брокеру нечем принимать решение; отказ в закрытую, потому что «запрещено» и «политики нет» не должны выглядеть одинаково |
Агент обязан различать: 4xx (кроме 429) — не ретраить, показать пользователю; 429/5xx — экспоненциальный backoff с jitter, максимум 15 мин.
Последние два делят protocol_unsupported с 400 выше, а не заводят свои коды: все
три говорят «ты попросил то, чего в этом контракте нет», а код — это то, что обе
реализации пинят фикстурой. Важно, что это вообще 4xx. Раньше оба были
503 ca_unavailable с retry_after: 60 — здоровый брокер сообщал агенту, что он
лежит, из-за пути или метода, которых никогда не будет, и отправлял читателя лога
смотреть на CA.
7. Аттестация#
Всё описанное выше работает и без аттестации, и устройство, которое её никогда не шлёт, продолжает работать — просто остаётся на key_protection_verified: software. Этот раздел о том, как сервер перестаёт верить агенту на слово.
Два утверждения доказываются раздельно, потому что их слияние преувеличило бы известное:
| Доказательство | Что устанавливает | Уровень |
|---|---|---|
TPM2_Certify ограниченным AK плюс fixedTPM\|fixedParent на ключе |
самосогласованную цепочку: сертифицированное имя принадлежит присланному ключу, а тот ключ — тот же, что в CSR | attested |
запиненный EK, отвеченный через MakeCredential/ActivateCredential |
тот же EK, что был запинен при регистрации | device_attested |
Второй столбец надо читать буквально: цепочка ни во что не упирается. Цепочки EK-сертификатов вендоров не проверяются (ограничения аттестации), а без них всё, что проверяет брокер, приходит от устройства: бит restricted у AK, fixedTPM|fixedParent у ключа и сам EK — это байты, которые написал вызывающий. Проверки настоящие, и каждая закрывает способ смошенничать частью цепочки; чего ни одна из них не может — установить, что TPM вообще существует.
Устройство, контролирующее собственный агент, может собрать согласованные AK, EK и PCR-доказательства программно. Брокер может принять их как device_attested; в аудите такое доказательство неотличимо от аппаратного. Поэтому эти уровни обозначают непрерывность от регистрации, а доверие к первой регистрации опирается на одноразовый токен, выданный оператором владельцу.
Что уровни действительно несут — это непрерывность с момента регистрации, ради неё пиннинг и делается: диск, перенесённый на машину с другим TPM, и TPM, подменённый или очищенный под уже зарегистрированным устройством, останавливаются здесь. Клонированная VM — нет (§7 ниже и ограничения аттестации). Что именно пинится, решает тот, кто владеет устройством в момент регистрации, и это размен, на который дизайн пошёл сознательно, чтобы не валидировать цепочки сертификатов пяти вендоров.
7.1 Полезная нагрузка доказательства#
При регистрации объект ниже передаётся в attestation. При подтверждении
сессии ответ на credential challenge передаётся в attestation_response
(§7.3), а доказательство загрузки — отдельно в boot_attestation (§7.3a).
{
"kind": "tpm2",
"ek_pub": "<base64 TPMT_PUBLIC>", // пиннится при регистрации
"ak_pub": "<base64 TPMT_PUBLIC>", // ограниченный ключ подписи, подписавший certify_info
"key_pub": "<base64 TPMT_PUBLIC>", // публичная область самого ключа устройства
"certify_info": "<base64 TPMS_ATTEST>", // вывод TPM2_Certify
"certify_sig": "<base64 TPMT_SIGNATURE>"
}
Все пять — структуры формата TPM, а не просто ключи: MakeCredential работает над TPMT_PUBLIC, а подпись certify ничего не значит без публичной области AK.
key_pub не дублирует CSR, и пропустить его — та ошибка, которую легко сделать. Структура certify называет ключ хэшем его публичной области, поэтому без неё сервер может проверить безупречную аттестацию о каком-то ключе и подписать сертификат для другого. Поэтому брокер пересчитывает имя из key_pub, сверяет его с certify_info и сверяет key_pub с публичным ключом из CSR. Обе связки — или ни одна из них ничего не значит.
7.2 Откуда берётся nonce#
Ни один из обменов не добавляет лишнего round trip.
- Регистрация использует enroll-токен как qualifying data:
sha256("nac-enroll-v1" || token). Серверного nonce на этом шаге ещё нет, но токен уже обладает всеми нужными свойствами — выдан сервером, непредсказуем, одноразов, — поэтому привязанное к нему доказательство становится непригодным, как только токен потрачен. - Каждое последующее доказательство отвечает на challenge из предыдущего ответа. TPM восстанавливает секрет с помощью EK, а брокер сверяет полученное значение с challenge. Это подтверждает непрерывность до EK, закреплённого при регистрации, но не подлинность аппаратуры (§7).
- Boot quote, когда его запрашивают, нонсится тем же секретом:
sha256("nac-quote-v1" || secret).
Формулы для регистрации и для quote обязаны совпадать на обеих сторонах побайтово, иначе аттестация отвергается как ответ на чужой nonce.
7.3 Credential challenge#
Возвращается в credential_challenge у /v1/enroll/ и
/v1/session/prove/; значение null означает, что challenge не выдан:
{
"kind": "tpm2",
"credential_blob": "<base64 TPM2B_ID_OBJECT>",
"encrypted_secret": "<base64 TPM2B_ENCRYPTED_SECRET>"
}
Ответ приходит в следующем запросе:
Этот объект передаётся в attestation_response следующего запроса
/v1/session/prove/. В нём только восстановленный секрет; quote загрузки
передаётся отдельно в поле boot_attestation.
7.3a Аттестация загрузки#
Присылается в boot_attestation на /v1/session/prove/, рядом с ответом на
credential challenge и с тем же nonce. Необязательна в том же смысле, что и
ответ на челлендж: её просят по каденсу непрерывности, а между этими моментами
доказательство не несёт ни того, ни другого.
{
"kind": "tpm2",
"ak_pub": "<base64 TPMT_PUBLIC ключа AK, который подписывает quote>",
"pcrs": [0, 1, 2, 3, 4, 5, 6, 7],
"values": { "7": "<base64 значения PCR>" },
"quoted": "<base64 TPMS_ATTEST>",
"signature": "<base64 TPMT_SIGNATURE>"
}
Два из этих полей устройство не выбирает, и оба сверяются с подписанной структурой, а не принимаются как присланы:
ak_pub— это тот AK, который уже доказан цепочкой certify при регистрации: брокер держит его отпечаток и отказывает quote, подписанному чем-либо ещё. Раньше пин брался из первого же quote, то есть первый проверялся против ключа, который прислало само устройство; софтовый ключ и выдуманные значения PCR принимались, а дальше каждый quote был самоподписанным;pcrsзафиксирован контрактом как 0–7, и брокер проверяет выборку внутри подписанного quote, а не это поле. Устройство, выбирающее регистры само, заставляет настоящий TPM подписать правдивое утверждение ни о чём: PCR 23 сбрасывается на ходу, поэтому цепочка, запиненная по нему одному, читается одинаково после загрузки во что угодно.
Пока это держится, перед нами единственное измерение, которое агент,
скомпрометированный после регистрации, подделать не может — подписывает TPM, а
не софт, — и потому posture стоит
рядом с key_protection, а не вместо него. Уточнение несущее: пин, на котором всё
держится, — это AK из certify-цепочки, поэтому устройство, зарегистрировавшееся
с поддельной цепочкой, вечно подписывает свои цитаты само, с любыми значениями PCR. Брокер выводит из него
verified_boot_state и больше ниоткуда: pinned для цепочки, увиденной первой
(доверие при первом предъявлении, как у EK), changed — когда то же устройство
предъявляет другую, invalid — когда доказательство пришло и не проверилось, в
том числе когда оно не разбирается. Устройство, которое просто ничего не
прислало, остаётся с тем, что доказало в прошлый раз: каденс не должен быть
способом потерять доступ.
Чего агент делать не должен — сообщать состояние загрузки сам. Поля для этого
нет, и политика, спрашивающая verified_boot_state, спрашивает про утверждение
TPM.
7.4 Что брокер делает при неудаче#
| Ситуация | Итог |
|---|---|
| доказательств нет вовсе | verified остаётся software: отсутствие доказательства доказательством не является |
| доказательство не проверяется | 400 attestation_invalid плюс событие аудита — но никогда молчаливое сползание в строку выше |
| устройство аттестовалось раньше и перестало | обновление проходит, verified опускается до software, изменение записывается в аудит |
kind не tpm2 |
отклоняется как непроверяемый, хотя схема уже держит cng и apple |
Третья строка сделана намеренно. TPM сбрасывают, машины пересобирают; отказ в обновлении отнял бы у устройства доступ из-за аппаратного события — ровно та беда, ради предотвращения которой всё это и строится. Понижение уровня с записью в аудит оставляет Policy Engine решать, чего это стоит.
8. Фикстуры#
docs/protocol/fixtures/:
enroll_request_tpm.json enroll_response.json
enroll_request_software.json enroll_response_attested.json
enroll_request_attested.json renew_response.json
renew_request.json renew_response_attested.json
renew_request_attested.json device_self_response.json
device_update_response.json errors.json
Криптоматериал в фикстурах — заглушки фиксированного вида ("-----BEGIN CERTIFICATE REQUEST-----\nFIXTURE\n-----END …"): фикстуры проверяют форму запроса, а не подписи. Проверка подписей — дело unit-тестов каждой стороны.
Агент держит копию в internal/protocol/testdata/; CI обоих репозиториев сверяет sha256 каталога и падает при расхождении. Обновление контракта — сначала фикстуры здесь, затем копия у агента.