diff --git a/CLAUDE.md b/CLAUDE.md index e6ed7e68..78e49705 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,8 @@ improvements'ами (CRL cache, OCSP parallel, CAdES-T fixes, request log, health indicator). Сохранена для возможности PR'а в upstream malikzh/NCANode. v4 в upstream не пойдёт (другой язык). -- **Состояние v4:** functional + 467 тестов / **90% coverage**. +- **Состояние v4:** functional + 504 теста (+15 эталонов NCALayer, когда + боевая PKI отвечает) / **90% coverage**. CI/CD обновлён под Java 25 + actions из demo-pki-center. Batch endpoints (issue #212) реализованы для всех сервисов. @@ -205,7 +206,8 @@ JWT/PDF/X509/PKCS12) начнёт возвращать `valid=false` из-за ` NCA SDK 2.0 test pack, заменить p12 в `p12/`, сверить новый период валидности, обновить эту дату. -467 тестов / **90% line coverage**. +504 теста (+15 эталонов NCALayer, когда боевая PKI отвечает) / +**90% line coverage**. ## test.pki.gov.kz — официальная тестовая PKI @@ -232,7 +234,7 @@ REVOKED-ветка покрывается через mock'нутый `CrlIndex`, ```bash ./gradlew bootJar # сборка -./gradlew test # 467 тестов + JaCoCo report +./gradlew test # 504 теста + JaCoCo report ./gradlew test jacocoTestReport # явно java -jar build/libs/NCANode-4.0.0-SNAPSHOT.jar # запуск приложения @@ -1135,6 +1137,109 @@ DER-ридер CRL на обрезанных и не-DER входах; повт за кэш; ветки WSSE, где сертификат подписанта не достаётся из `SecurityTokenReference`. +### 45. Приказ №522/НҚ (в силе с 11.09.2026): что он поменял у нас +Полная сверка — `rules-2026-compliance.md`. Приказ регулирует УЦ, а не нас; +применимы только профили сертификатов/СОС/OCSP/TSP. По существу мы им +соответствовали — профили кодифицируют то, что НУЦ уже выпускает (сверено с +ключами SDK 2.0 в репо). Важное, что всплыло: + +- **Разностный СОС в профиле помечен `freshestCRL 2.5.29.46, critical`**, а не + `deltaCRLIndicator`. Отсюда два изменения в `CrlService`: расширение внесено в + allowlist критичных (указатель охват не сужает), а base теперь ищется только + среди файлов **не с delta-эндпоинта** (`UsableCrl.fromDeltaEndpoint`). Без + второго delta без индикатора выигрывала бы отбор base по `CRLNumber` + (57 725 против 1 346) и вердикт считался бы по ней одной — всё, отозванное + только в полном списке, вернулось бы ACTIVE. +- **Ответ-ошибка OCSP (`status != 0`) теперь UNAVAILABLE, а не UNKNOWN.** + Он не подписан и о сертификате не говорит ничего (RFC 6960 §4.2.1), а + UNKNOWN в `isValid` фатален — `tryLater` от одного из двух обязательных + теперь респондеров ронял валидную подпись. Пустое тело ответа — там же: + раньше `OCSPResp(ByteArray(0))` давал NPE мимо всех catch → 500. +- **Зеркала.** Профили дают по два адреса и в AIA, и в CDP. OCSP: обход + прекращается на первом авторитетном ответе. CRL: `crlDistributionPoints` + группирует адреса по точкам распространения (внутри точки это один и тот же + список, RFC 5280 §4.2.1.13), качается первый сработавший — иначе в кэше + оказывались две копии 20-МБ СОС. +- **`freshestCRL` теперь читается** (`freshestCrlDistributionPoints`): адрес + delta НУЦ публикует именно там, и для издателя вне конфигурации мы работали + на одном полном списке. Скачанное по нему кладётся в ОТДЕЛЬНЫЙ каталог + `crl//ondemand-delta`: каталог — единственный носитель провенанса, и + без разделения delta без индикатора выиграла бы отбор base уже через + on-demand путь (та же дыра, что закрыта для конфигурационного). Потолок + `NCANODE_CRL_ONDEMAND_MAX` общий на оба on-demand каталога. +- **Шаблоны «цифровая система»** (`1.2.398.3.3.4.1.1.1`, + `1.2.398.3.3.4.1.2.6`) и «Казначейство — Клиент» (`1.2.398.5.19.1.2.2.1`) + добавлены в `CertificateKeyUser`; `UID` (OID цифровой системы), + `businessCategory` (код клиента Казначейства) и `DC` (роль) — в + `CertificateSubject`. Все они есть на наших же тестовых ключах + (`legal_infosystem_valid.p12`, `legal_treasury_valid.p12`), то есть занижали + вывод `/x509/info` уже сегодня. Попутно чинилось отчество: `X500Principal + .toString()` печатает его как `GIVENNAME`, а разбор ждал только `G`, поэтому + `subject.lastName` был пуст на ВСЕХ сертификатах НУЦ. `businessCategory` + приходит там же как `OID.2.5.4.15` — keyword'а у него нет. + ⚠️ Фикстуры на «цифровую систему физического лица» (`1.2.398.3.3.4.1.1.1`) + в тест-паке нет — `INDIVIDUAL_DIGITAL_SYSTEM` не покрыт. +- **Дефолты**: `ncanode.ocsp.url` — оба адреса НУЦ; `NCANODE_CA_CRL_TTL` + 1440 → 720 (КУЦ обновляет свой СОС не реже раза в 24 ч). +- ⏰ **Операционное**: сертификат на файловом носителе теперь действует + **1 год** (3 года — токен/облако/удостоверение личности и оба шаблона + «цифровая система»). Для серверного подписанта правильный шаблон — + «цифровая система юридического лица». +- Сама процедура проверки подписи описана **другим** приказом («Правила + формирования и проверки подлинности ЭЦП», пп. 4 п. 1 ст. 55 Цифрового + кодекса) — его нужно сверять отдельно. + + +### 46. Приказ №500/НҚ: Правила проверки подлинности — профильный для нас документ +Сверка — `rules-2026-verification-compliance.md`. В отличие от №522/НҚ (он про +УЦ), этот приказ описывает саму процедуру формирования и проверки подписи, то +есть работу сервиса. Что изменилось: + +- **Срок действия проверяется у всей цепочки** (п. 16.1), а не только у + непосредственного издателя: `isChainDateValid` обходит уже проставленные + `issuerCertificate` (лишних поисков нет) и останавливается на самоподписанном + либо там, где издателя в бандле нет — всё из `NCANODE_CA_URL` считается + настроенным якорем доверия. Корень `CaService` ссылает сам на себя, поэтому + без защиты от петли обход зациклился бы. +- **`CrlResult.EXPIRED`** (п. 18.5): CRL за пределами `nextUpdate` больше не даёт + положительного вердикта. Нефатален ровно тогда, когда есть авторитетный ACTIVE + от OCSP — п. 16.2 разрешает проверять отзыв «посредством OCSP **либо** CRL». + REVOKED из протухшего списка остаётся в силе. ⚠️ CRL-фикстуры в репозитории + протухли (nextUpdate — октябрь 2025), поэтому юнит-тесты на них ждут EXPIRED, + а ACTIVE проверяется на mock'ах и живых списках. +- **`permitsSignature`** (п. 16.4): `keyUsage` должен разрешать digitalSignature + либо nonRepudiation; отсутствие расширения — не ограничение. ⚠️ Требование + относится к сертификату ПОДПИСАНТА, поэтому включается флагом + `isValid(..., requireSigningKeyUsage = true)` — его передают Cms/Xml/Pdf/Wsse/ + Jws/Tsp, SBA-verify и pre-sign gate. Без флага (`/x509/info`, `/pkcs12/info`, + `verifyCerts`) проверки нет: у CA-сертификатов НУЦ `keyUsage` = keyCertSign + + cRLSign, и глобальная проверка делала их `valid:false` на info-эндпойнтах. + Номер политики НЕ enforce'им (условия задаёт УЦ, из сертификата не выводимы) + — вместо этого публикуем `certificates[].policies`. +- **`signingCertificateV2` сверяется и в `/cms/verify`** (п. 8), не только на + AdES-пути: `CadesInspector.signingCertificateMatches` стал публичным. + +Сознательные расхождения (обоснования — в документе): не требуем совпадения +издателя TSA с издателем подписанта (п. 17.2 — иначе отвергали бы боевые +RSA-метки НУЦ на GOST-подписях, quirk #25); не требуем EKU OCSPSigning от +самого CA (п. 19.5 — RFC 6960 §4.2.2.2 разрешает CA подписывать ответы своим +ключом, так НУЦ и делает); п. 19.6 читаем как «квитанция должна свидетельствовать +о моменте проверки» (`authoritativeAt`), буквально он невыполним. + +- **Проверка сертификата ПЕРЕД подписанием** (п. 4) — + `CertificateService.ensureSignerCertificateUsable` во всех sign-путях (10 мест), + отказ 400 на непригодном ключе. **Выключена по умолчанию** + (`NCANODE_SIGN_CERT_CHECK`): включение меняет поведение всех sign-эндпойнтов и + добавляет OCSP-запрос на каждое подписание. Вердикт тот же, что у верификации, + поэтому «подписали — проверили» не расходится. Криптопроверку сертификата + ключом УЦ даёт сам поиск издателя (`getRootCertificateFor` сверяет подпись). + +Ещё наблюдение: `AdesReferenceCompatibilityTest` проверяет доступность +`pki.gov.kz` один раз на старте спеки. Если боевая PKI замолкает **в середине** +прогона, краснеют все 15 эталонов при исправном коде (наблюдалось; повторный +прогон — 15/15 зелёные). + + ## Что не покрыто тестами (≈441 line) | Пакет | % | Что осталось | diff --git a/docs/_tabs/docs.md b/docs/_tabs/docs.md index e313bed9..0006ff6d 100644 --- a/docs/_tabs/docs.md +++ b/docs/_tabs/docs.md @@ -64,7 +64,8 @@ docker run -p 14579:14579 -v ncanode_cache:/app/cache -d malikzh/ncanode * `NCANODE_PROXY_URL` - Прокси для HTTP-клиента. Через этот прокси будут происходить все запросы из NCANode. * `NCANODE_PROXY_USERNAME` - Имя пользователя в прокси * `NCANODE_PROXY_PASSWORD` - Пароль прокси -* `NCANODE_OCSP_URL` - OCSP-сервер куда будут происходить запросы. По умолчанию: http://ocsp.pki.gov.kz/ +* `NCANODE_SIGN_CERT_CHECK` - Проверять сертификат подписанта **перед** формированием подписи — п. 4 Правил формирования и проверки подлинности ЭЦП (приказ МИИ РК №500/НҚ от 21.08.2026): подпись удостоверяющего центра, срок действия, отсутствие отзыва (OCSP, при его недоступности — CRL) и допустимость назначения ключа. Если `true`, подписание непригодным ключом отклоняется с кодом 400 и причиной; вердикт выносится теми же средствами, что и верификация, поэтому совпадает с тем, что потом скажет `/{cms,xml,pdf}/verify` о готовой подписи. По умолчанию: `false` — включение меняет поведение всех sign-эндпойнтов и добавляет обращение к OCSP на каждое подписание. Развёртыванию, которое обязано соответствовать Правилам, флаг нужен. +* `NCANODE_OCSP_URL` - OCSP-серверы куда будут происходить запросы, через пробел. Опрашиваются по очереди до первого авторитетного ответа. По умолчанию: http://ocsp.pki.gov.kz/ http://ocsp1.pki.gov.kz/ * `NCANODE_OCSP_STRICT` - Строгий режим OCSP (SSRF-защита). Если `true`, OCSP-проверка игнорирует адрес responder'а из AIA сертификата и ходит только на сконфигурированный `NCANODE_OCSP_URL`. По умолчанию: `false` (AIA-first, config-fallback). * `NCANODE_CA_URL` - URL-ы корневых сертификатов. Они скачиваются автоматически при запуске NCANode. По умолчанию: * https://pki.gov.kz/cert/nca_rsa.crt @@ -130,6 +131,14 @@ docker run -p 14579:14579 -v ncanode_cache:/app/cache -d malikzh/ncanode * **CRL издателя нет в кэше** (CA не публикует CRL, или он не сконфигурирован) — CRL-канал помечается `"result": "UNAVAILABLE"`; это не мешает валидности при живом OCSP, но такой CRL не может служить fallback-источником. +* **CRL найден, но его период действия истёк** (`nextUpdate` в прошлом либо + отсутствует) — канал помечается `"result": "EXPIRED"`, и это отрицательный + результат проверки отзыва (п. 18 Правил формирования и проверки подлинности + ЭЦП). Отсутствие серийника в протухшем списке ничего не доказывает: всё, что + издатель опубликовал после `nextUpdate`, в него не попало. Не фатально это + ровно тогда, когда авторитетный ответ уже получен от OCSP — Правила + разрешают проверять отзыв «посредством сервиса OCSP **либо** CRL». Запись об + отзыве из такого списка остаётся в силе. CRL-проверка учитывает delta-CRL по RFC 5280 §5.2.4: если для издателя есть и полный (base), и применимая к нему delta (`NCANODE_CRL_DELTA_URL`), они @@ -139,10 +148,29 @@ CRL-проверка учитывает delta-CRL по RFC 5280 §5.2.4: есл чем по одному полному CRL. Каждый элемент `revocations[]` в ответе несёт поле `result` -(`ACTIVE` / `REVOKED` / `UNKNOWN` / `UNAVAILABLE`). Клиент, которому нужна +(`ACTIVE` / `REVOKED` / `UNKNOWN` / `UNAVAILABLE` / `EXPIRED`). Клиент, которому нужна строгая политика «оба канала обязаны ответить», может реализовать её сам: `valid && revocations.none { result == "UNAVAILABLE" }`. +## Проверка сертификата перед подписанием + +Правила формирования и проверки подлинности ЭЦП (приказ МИИ РК №500/НҚ) +требуют, чтобы **до** формирования подписи подписывающая сторона проверила +сертификат: подпись удостоверяющего центра, срок действия, отсутствие отзыва +(OCSP, при его недоступности — CRL) и допустимость назначения ключа. При +подписании через цифровую систему это обязанность её владельца. + +Включается флагом `NCANODE_SIGN_CERT_CHECK=true` и действует на все +sign-эндпойнты (`/cms`, `/xml`, `/pdf`, `/wsse`, `/jwt`, `/jws`, `/x509/sign`, +а также `/cades`, `/xades`, `/pades`). Непригодный ключ получает отказ **400** +с указанием субъекта сертификата и статусов отзыва; причина отказа пишется в +лог. Вердикт выносится теми же средствами, что и верификация, поэтому +совпадает с тем, что потом скажет `/{cms,xml,pdf}/verify` о готовой подписи. + +По умолчанию выключено: включение меняет поведение всех sign-эндпойнтов +(просроченный или отозванный ключ перестаёт подписывать) и добавляет обращение +к OCSP на каждое подписание. + ## Подписи AdES (CAdES / XAdES / PAdES) Помимо привычных `/cms`, `/xml` и `/pdf` есть три семейства эндпойнтов, diff --git a/openapi.yml b/openapi.yml index fa323b5c..a2b0e305 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1149,12 +1149,15 @@ components: type: string enum: - INDIVIDUAL + - INDIVIDUAL_DIGITAL_SYSTEM - ORGANIZATION - CEO - CAN_SIGN - CAN_SIGN_FINANCIAL - HR - EMPLOYEE + - ORGANIZATION_DIGITAL_SYSTEM + - TREASURY_CLIENT - NCA_PRIVILEGES - NCA_ADMIN - NCA_MANAGER @@ -1164,6 +1167,15 @@ components: - IDENTIFICATION_REMOTE - IDENTIFICATION_REMOTE_DIGITAL_ID uniqueItems: true + policies: + type: array + items: + type: string + description: >- + OID'ы политик применения сертификата (расширение certificatePolicies). + Публикуются, чтобы проверяющая сторона могла сверить номер политики с + условиями её применения (п. 16 Правил формирования и проверки + подлинности ЭЦП). publicKey: type: string signature: @@ -1194,6 +1206,7 @@ components: - REVOKED - UNKNOWN - UNAVAILABLE + - EXPIRED CertificateSubject: type: object properties: @@ -1217,6 +1230,21 @@ components: type: string state: type: string + uid: + type: string + description: >- + UID (0.9.2342.19200300.100.1.1). У сертификатов шаблона «цифровая + система юридического лица» здесь OID самой цифровой системы. + businessCategory: + type: string + description: >- + businessCategory (2.5.4.15). Обязательное поле шаблона «участник + цифровой системы "Казначейство – Клиент"» — код клиента вида KS01234. + domainComponent: + type: string + description: >- + domainComponent (0.9.2342.19200300.100.1.25). Обязательное поле того + же шаблона — роль участника вида ROLE01. dn: type: string VerificationResponse: diff --git a/rules-2026-compliance.md b/rules-2026-compliance.md new file mode 100644 index 00000000..691f60fb --- /dev/null +++ b/rules-2026-compliance.md @@ -0,0 +1,209 @@ +# Приказ МИИ РК №522/НҚ — сверка NCANode v4 + +Рабочий артефакт ветки `feature/rules-2026-compliance` (как +`pki-rfc-audit-plan.md` и `code-audit-plan.md` — **не** для upstream-PR). + +**Документ:** приказ и.о. Заместителя Премьер-Министра — Министра +искусственного интеллекта и цифрового развития РК от 28.08.2026 № 522/НҚ, +зарегистрирован в Минюсте 31.08.2026 № 39747. Правила выдачи, хранения, +отзыва сертификатов открытого ключа ЭЦП и подтверждения принадлежности и +действительности открытого ключа ЭЦП — четырьмя приложениями: КУЦ РК, +УЦ ГО РК, **НУЦ РК** (приложение 3 — наше), прочие УЦ. +**В силе с 11.09.2026.** Отменяет приказы №727 (2015), №1183, №1231, +№383/НҚ (2020) и пять изменяющих. + +## Что из приказа вообще относится к нам + +NCANode — не удостоверяющий центр, поэтому порядок выдачи, работа ЦР, +удалённая идентификация, госуслуга и сроки рассмотрения жалоб к нам не +применимы. Остаётся два блока: + +1. **Профили сертификатов, СОС, OCSP- и TSP-сертификатов** (приложение 2 + к каждому из четырёх приложений) — их обязана понимать наша проверка. +2. **Отсылка**: п. 27 (НУЦ) и п. 34 (прочие УЦ) говорят, что подтверждение + принадлежности и действительности открытого ключа, *включая проверку + подлинности ЭЦП*, выполняется по **«Правилам формирования и проверки + подлинности ЭЦП»** (пп. 4 п. 1 ст. 55 Цифрового кодекса) — это + **отдельный приказ, которого в этом документе нет**. Для NCANode он и + есть профильный: там описан сам алгоритм проверки. ⚠️ Его нужно + получить и сверить отдельно — эта сверка его не покрывает. + +Приложение 4 (прочие УЦ) прямо ссылается на **RFC 5280** (структура +сертификатов и СОС) и **RFC 6960** (OCSP) — то есть на ту же базу, на +которой построена наша проверка. + +## Вердикт + +**По существу соответствуем.** Новые профили кодифицируют то, что НУЦ уже +выпускает: сверено с ключами NCA SDK 2.0 из `src/test/resources/p12` — +`keyUsage` critical = digitalSignature + nonRepudiation + keyAgreement, +EKU `1.2.398.3.3.4.1.*`, политика `1.2.398.3.3.2`, AIA + CDP + freshestCRL +на месте. Алгоритмов и структур, которых мы не умеем, в приказе нет: +ГОСТ 34.310-2004 (`1.2.398.3.10.1.1.1.2`), ГОСТ 34.10-2015 +(`1.2.398.3.10.1.1.2.3.2`), RSA-2048/4096 — всё поддержано. Строгая +EKU-проверка TSA (critical, единственный `id-kp-timeStamping`) профилем +подтверждается. + +Найдено 6 расхождений, два — с риском неверного вердикта. Плюс одна +находка «в довесок» (NPE), вскрытая тестом. + +## Закрыто в этой ветке + +### 1. Разностный СОС: `freshestCRL` critical и ложный base (HIGH, латентный) + +Профили разностных СОС (приложение 3, структуры **13 и 14**) маркируют +delta расширением **`Freshest CRL 2.5.29.46, critical`** — вместо +`deltaCRLIndicator 2.5.29.27`. Похоже на ошибку составителя (RFC 5280 +§5.2.6 требует `freshestCRL` **некритичным**, а delta обязана нести +`deltaCRLIndicator`), но выпускать могут по букве. Наши последствия были +два, и второе тяжёлое: + +- allowlist критичных расширений СОС состоял из одного + `deltaCRLIndicator` → такой список отбрасывался целиком; +- если бы индикатора в нём не оказалось, delta попадала бы в пул **base**, + а base выбирается по максимальному `CRLNumber` — у delta он на порядки + больше (на бою 57 725 против 1 346). Delta выиграла бы отбор, вердикт + считался бы по ней одной, и всё, что отозвано только в полном списке, + вернулось бы как **ACTIVE**. + +Сделано (`CrlService`): +- `SUPPORTED_CRITICAL_CRL_EXTENSIONS` += `freshestCRL`. Игнорировать + указатель безопасно — он не сужает охват списка; +- base ищется только среди файлов **не с delta-эндпоинта** + (`UsableCrl.fromDeltaEndpoint` — провенанс, а не содержимое). Список с + delta-эндпоинта без индикатора не станет base и, не имея + `baseCRLNumber`, не будет применён как delta — консервативно работаем + на полном списке, с WARN в лог. + +Тесты (`CrlServiceTest`): delta с critical `freshestCRL` применяется; base +с critical `freshestCRL` пригоден; список с delta-эндпоинта без индикатора +не подменяет base (вердикт остаётся REVOKED по полному списку). + +### 2. Ответ-ошибка OCSP объявлял подпись недействительной (MEDIUM) + +Профили обязывают НУЦ держать **два респондера** (`ocsp.pki.gov.kz` + +`ocsp1.pki.gov.kz` в AIA). У нас `resp.status != 0` давал `UNKNOWN`, а в +`CertificateWrapper.isValid` **любой** UNKNOWN фатален — даже когда +соседний респондер ответил ACTIVE. То есть `tryLater` от перегруженного +зеркала объявлял валидную подпись недействительной. По RFC 6960 §4.2.1 +ответ-ошибка **не подписан** и о сертификате ничего не сообщает — это +«ответа нет». + +Сделано (`OcspService`): статусы 1/2/3/5/6 → `UNAVAILABLE` с расшифровкой +имени (`tryLater (3)` и т. д.); допускается штатная деградация на свежий +CRL. Побочно закрыт вектор: неподписанный ответ-ошибку мог подделать +кто угодно на пути и этим ронять проверку. + +### 3. Зеркала удваивали трафик (MEDIUM) + +- **OCSP**: опрашивались все адреса из AIA без раннего выхода. Теперь + обход прекращается на первом авторитетном ответе; `UNAVAILABLE` + авторитетным не считается — на нём как раз и переходим к зеркалу. +- **CRL**: `crlList` отдавал плоский список URL, и зеркало из той же точки + распространения скачивалось **вторым экземпляром** того же 20-МБ списка + (со своим индексом, и оба занимали места в LRU on-demand кэша). Теперь + `crlDistributionPoints` группирует адреса по точкам (RFC 5280 §4.2.1.13: + имена внутри одной точки — адреса одного и того же списка), и внутри + группы качается первый сработавший. + +Тесты: `OcspServiceTest` — обход всех адресов, пока ответы UNAVAILABLE, и +остановка на первом авторитетном; `CrlServiceTest` — одна загрузка на +точку распространения с двумя зеркалами. + +### 4. `freshestCRL` (2.5.29.46) не читался вовсе (MEDIUM) + +Все профили кладут адрес разностного СОС именно туда (это видно и на +тестовых ключах НУЦ), а on-demand загрузка читала только +`cRLDistributionPoints`. Для издателя, которого нет в конфигурации, мы +работали на одном полном списке — то есть не видели досрочных отзывов, +ради которых delta и публикуется, и `fresh` считался по полному списку. +Это ослабляло ровно тот fallback, который делает OCSP-недоступность +непроблемой (quirk #28). + +Сделано: `CertificateWrapper.freshestCrlDistributionPoints` + +`fetchOnDemandCrls` обходит обе группы расширений. SSRF-фильтры (схема, +`isInternalHost`, strict-режим) те же. + +Скачанное по `freshestCRL` кладётся в отдельный каталог +`crl//ondemand-delta`. Иначе фикс из пункта 1 обходился бы этим же +путём: провенанс списка выводится из каталога, и delta без +`deltaCRLIndicator`, попавшая в общий on-demand каталог, снова выигрывала бы +отбор base по `CRLNumber`. Потолок `NCANODE_CRL_ONDEMAND_MAX` общий на оба +каталога — это один кэш, просто разложенный по происхождению. + +### 5. Новые OID шаблонов (LOW) + +`CertificateKeyUser` пополнен: `1.2.398.3.3.4.1.1.1` +(`INDIVIDUAL_DIGITAL_SYSTEM` — «цифровая система физического лица»), +`1.2.398.3.3.4.1.2.6` (`ORGANIZATION_DIGITAL_SYSTEM` — «цифровая система +юридического лица»), `1.2.398.5.19.1.2.2.1` (`TREASURY_CLIENT` — +«Казначейство — Клиент»). Нераспознанные OID молча выпадали +(`mapNotNull`), и `/x509/info` занижал шаблон — причём уже сегодня: эти +OID есть на наших же `legal_infosystem_valid.p12` и +`legal_treasury_valid.p12`. `openapi.yml` обновлён. + +### 6. `UID` в Subject (LOW) + +В шаблоне «цифровая система юридического лица» `UID` +(`0.9.2342.19200300.100.1.1`) несёт **OID самой цифровой системы**, и по +п. 17 Правил закрытый ключ ставится именно в систему с этим OID. Поле +добавлено в `CertificateSubject` (и в `openapi.yml`); раньше его можно +было достать только разбором сырого `dn`. + +### 7. Пустое тело ответа OCSP → NPE (найдено тестом) + +`OCSPResp(ByteArray(0))` падает `NullPointerException`, а он не ловится ни +одной из веток `exchange()` и уходил наружу — 500 на весь verify вместо +вердикта. Теперь пустое тело — `UNAVAILABLE`. + +### Конфигурация + +- `ncanode.ocsp.url` по умолчанию — оба адреса + (`ocsp.pki.gov.kz` + `ocsp1.pki.gov.kz`), как в профилях. Используется + при отсутствии AIA и в strict-режиме; с ранним выходом лишних запросов + не даёт. +- `NCANODE_CA_CRL_TTL` 1440 → **720**. КУЦ РК обновляет свой СОС «не реже + одного раза в двадцать четыре часа» (приложение 1, п. 15); TTL в те же + 24 часа означал худший случай «весь цикл на устаревшем списке». + +## Не делаем (осознанно) + +- **Зеркала CRL в `ncanode.crl.url`.** Плановая загрузка качает каждый URL + независимо — добавление `crl1.pki.gov.kz` означало бы вторую копию + 20-МБ списка на каждом цикле. Отказоустойчивость планового пути уже + обеспечена повторами (`NCANODE_CRL_RETRIES`, quirk #42), а зеркала из + сертификата подхватываются on-demand. **Для strict-режима** (адреса из + сертификата игнорируются) зеркала стоит прописать вручную. +- **Проверка «сертификат выпущен по действующему шаблону».** Приказ + описывает профили, но не требует от проверяющей стороны отвергать + сертификат за отступление от шаблона, а НУЦ выпускает и старые. Наша + задача — понимать оба. + +## Операционное (в доки, не в код) + +- **п. 15 (НУЦ): сертификат на электронном носителе (файл, PKCS12) теперь + действует 1 год.** Три года — только для защищённого носителя, облачного + хранилища, удостоверения личности и обоих шаблонов «цифровая система». + Для сервера-подписанта это означает либо ежегодную ротацию p12, либо + переход на шаблон **«цифровая система юридического лица»** — он для + автоматического подписания однотипных документов и живёт три года. + Заодно это влияет на обновление тест-пака (дедлайн 2027-05-07 в + `CLAUDE.md`). +- **Терминология.** Всюду «сертификат открытого ключа ЭЦП»; + «регистрационное свидетельство» из отменённых приказов больше не + используется. +- **Делегированный OCSP-респондер** (`CN=OCSP RESPONDER`, EKU + `id-kp-OCSPSigning` + `id-pkix-ocsp-nocheck`) прописан в приказе + профилем (структура 15). Код это умеет + (`OcspService.findVerifiedResponderCertificate`), но ветка не покрыта + тестами — НУЦ до сих пор подписывал ответы ключом самого CA. С 11.09 + путь может стать боевым; тест потребует сохранённого ответа или своего + мини-респондера (см. вариант A в `CLAUDE.md`). +- **`ca.crl.url`** остаётся сверенным с реальными CRL DP промежуточных + (quirk #31) — приказ этого не меняет. + +## Следующий шаг + +Получить и сверить **«Правила формирования и проверки подлинности ЭЦП»** — +именно они, по п. 27/34 этого приказа, описывают процедуру проверки, +которую NCANode и реализует. diff --git a/rules-2026-verification-compliance.md b/rules-2026-verification-compliance.md new file mode 100644 index 00000000..0697866d --- /dev/null +++ b/rules-2026-verification-compliance.md @@ -0,0 +1,161 @@ +# Приказ МИИ РК №500/НҚ — сверка проверки подписи в NCANode v4 + +Рабочий артефакт ветки `feature/rules-2026-compliance` (как +`rules-2026-compliance.md` рядом — **не** для upstream-PR). + +**Документ:** приказ Заместителя Премьер-Министра — Министра искусственного +интеллекта и цифрового развития РК от 21.08.2026 № 500/НҚ, зарегистрирован в +Минюсте 24.08.2026 № 39672. **Правила формирования и проверки подлинности +электронной цифровой подписи.** Вводится в действие через десять календарных +дней после первого официального опубликования. Отменяет приказ №1187 (2015) и +изменяющий его №316 (2016). + +Это тот самый документ, на который ссылаются п. 27 и п. 34 приказа №522/НҚ, — +и, в отличие от него, **профильный для NCANode**: он описывает не работу +удостоверяющего центра, а сам порядок формирования и проверки подписи, то +есть ровно то, что делает сервис. По п. 22 обязанность соблюдать эти Правила +при подписании и проверке через цифровую систему лежит на владельце системы. + +## Что Правила требуют и что у нас есть + +| Требование | Пункт | Состояние | +|---|---|---| +| Криптопроверка подписи открытым ключом из сертификата подписанта | 15 | было | +| Сертификат подписанта обязан быть в документе либо получаем; иначе проверка невозможна | 15 | было (RFC-аудит, п. 1) | +| Срок действия **всей цепочки** до доверенного корня | 16.1 | **добавлено** | +| Отзыв: OCSP либо CRL (delta при наличии) | 16.2 | было | +| Корректность построения цепочки | 16.3 | было + усилено (16.1) | +| Соответствие Key Usage / Extended Key Usage и политики назначению | 16.4 | **добавлено** (KU) + публикуем политику | +| Проверка метки времени: подпись, идентификация TSA, EKU timeStamping, сверка хэша | 17 | было (строгая проверка TSP, quirk #25) | +| Момент проверки = genTime метки, иначе «сейчас» | 14 | было (quirk #26) | +| CRL: структура, издатель, соответствие сертификату, криптопроверка TBSCertList | 18.1–18.4 | было (`CrlIndex`, quirk #34) | +| **CRL: истёкший период действия — отрицательный результат** | 18.5 | **добавлено** | +| OCSP: структура, идентификация респондера, сверка CertID, криптопроверка, EKU OCSPSigning | 19.1–19.5 | было (RFC-аудит, quirk #32) | +| OCSP: `unknown` — отрицательный результат | 19.7 | было (quirk #28) | +| CAdES обязан нести `signingCertificateV2` | 8 | было в `/cades/*`; **привязка теперь сверяется и в `/cms/verify`** | +| Проверки сертификата **перед** формированием подписи | 4 | **добавлено**, `NCANODE_SIGN_CERT_CHECK` | + +## Что добавлено в этой ветке + +### 1. Срок действия всей цепочки, а не только издателя (п. 16.1, 16.3) + +Проверялся сертификат подписанта и его непосредственный издатель. Промежуточный +или корневой сертификат выше по цепочке мог быть просрочен — мы этого не +замечали, хотя п. 16 прямо говорит: «истечение срока действия хотя бы одного +сертификата в цепочке сертификации на момент проверки подлинности ЭЦП означает +отрицательный результат». + +`CertificateWrapper.isChainDateValid` обходит уже проставленные ссылки +`issuerCertificate` (их выставляет `CaService` для всего бандла), поэтому +дополнительных поисков не делает. Обход останавливается на самоподписанном +сертификате либо там, где издателя в бандле нет: всё, что попало в +`NCANODE_CA_URL`, — настроенный оператором якорь доверия. Это сознательно мягче +буквального «до корневого сертификата»: иначе проверка подписей старой +GOST-2004 иерархии ломалась бы у всех, у кого в бандле есть промежуточный НУЦ, +но нет снятого с публикации корня. Защита от петли — по паре (subject, +серийник): `CaService` ссылает самоподписанный корень сам на себя. + +### 2. Истёкший CRL больше не даёт положительного вердикта (п. 18.5) + +Было: список за пределами `nextUpdate` использовался как обычный, просто с +`fresh = false`. То есть при `revocationCheck: ["CRL"]` протухший список молча +выдавал «не отозван» — хотя всё, что издатель опубликовал после `nextUpdate`, в +него физически не попало. + +Стало: новый исход `CrlResult.EXPIRED` (и `RevocationResult.EXPIRED` в +`revocations[].result`). Он не фатален ровно в одном случае — когда отзыв уже +подтверждён по второму каналу: п. 16.2 требует проверки «посредством сервиса +OCSP **либо** CRL», и авторитетный ACTIVE от респондера самодостаточен. Иначе +проверка отрицательна. `REVOKED` из протухшего списка остаётся в силе — отзывы +не отменяются. + +Это заодно снимает старую асимметрию: `fresh` и раньше решал, годится ли CRL как +замена упавшему OCSP (quirk #28), но не мешал ему быть единственным основанием +в CRL-режиме. + +### 3. Назначение ключа должно допускать подпись (п. 16.4) + +`CertificateWrapper.permitsSignature`: `keyUsage` обязан разрешать +`digitalSignature` либо `nonRepudiation` (RFC 5280 §4.2.1.3, биты 0 и 1). +Отсутствие расширения — не ограничение. + +Требование относится к сертификату **подписывающего лица**, поэтому включается +параметром `isValid(..., requireSigningKeyUsage = true)`: его передают пути +проверки подписи (Cms, Xml, Pdf, Wsse, Jws, TSA) и проверка перед подписанием. +`/x509/info`, `/pkcs12/info` и `verifyCerts` отвечают на другой вопрос — «что +это за сертификат», — и там проверки нет: у CA-сертификатов НУЦ `keyUsage` = +keyCertSign + cRLSign, и глобальное требование делало бы их `valid: false`. Сертификаты НУЦ несут +digitalSignature + nonRepudiation + keyAgreement, так что на них это не влияет; +отсекается ключ, выданный только для шифрования. + +**Номер политики не enforce'им.** Условия применения политики задаёт УЦ, из +самого сертификата они не выводимы, а «просто сверить, что политика +1.2.398.3.3.2» означало бы отвергать сертификаты любого другого УЦ. Вместо +этого номера политик публикуются в `certificates[].policies`, и проверяющая +сторона применяет свои условия — это её решение по п. 16.4, а не наше. + +### 4. `signingCertificateV2` сверяется и на обычном `/cms/verify` (п. 8) + +Атрибут лежит под подписью и намертво привязывает её к конкретному сертификату +(RFC 5035). AdES-путь его сверял, обычный CMS — нет. Теперь сверяет: контейнер, +у которого атрибут указывает на другой сертификат, внутренне противоречив +независимо от того, каким эндпойнтом его проверяют. + +## Расхождения, оставленные сознательно + +**п. 17.2 — «сертификат сервиса метки времени выдан удостоверяющим центром, +выдавшим проверяемый сертификат».** Мы требуем, чтобы сертификат TSA строился +до доверенного корня и был валиден на genTime, но не требуем совпадения +издателя с издателем подписанта. Причина зафиксирована в quirk #25: НУЦ сам +выдаёт метки RSA-TSA от «НУЦ (RSA) 2022» на подписи, сделанные GOST-ключом от +«НУЦ (GOST) 2022» — какой TSA ответит, зависит от hash/policy в запросе +клиента. Буквальное применение п. 17.2 отвергало бы такие подписи, включая +выпущенные боевой инфраструктурой. Требование строже RFC 3161 и, судя по +поведению самой инфраструктуры, задумано как «TSA той же PKI». + +**п. 19.5 — EKU `OCSPSigning` «в сертификате сервиса OCSP либо +удостоверяющего центра».** Когда ответ подписан ключом самого CA (так делает +НУЦ сегодня), EKU у CA-сертификата нет и быть не должно — RFC 6960 §4.2.2.2 +прямо разрешает CA подписывать ответы собственным ключом. Мы требуем EKU +только от делегированного респондера. Буквальное прочтение п. 19.5 отвергало +бы текущие ответы НУЦ. + +**п. 19.6 — «момент предоставления OCSP-квитанции (thisUpdate) не предшествует +моменту проверки».** Буквально это невыполнимо: живой ответ формируется на +доли секунды раньше, чем мы его читаем, а при проверке «на сейчас» момент +проверки всегда позже `thisUpdate`. Смысл требования — квитанция не должна +относиться к более раннему моменту, чем проверяемый, — у нас реализован в +`authoritativeAt`: ACTIVE засчитывается, если интервал квитанции накрывает +момент проверки либо она выпущена позже него, но в пределах срока сертификата +(quirk #38). + +### 5. Проверка сертификата перед подписанием (п. 4) + +Правила требуют, чтобы **до** формирования подписи подписывающая сторона +проверила сертификат: криптографически (подпись УЦ), срок действия, отсутствие +отзыва (OCSP, при недоступности — CRL) и область применения. Через цифровую +систему это обязанность владельца системы (п. 22). + +`CertificateService.ensureSignerCertificateUsable` вызывается во всех +sign-путях: `/cms`, `/xml`, `/pdf`, `/wsse`, `/jwt`, `/jws`, `/x509/sign`, +`/cades`, `/xades`, `/pades`. Непригодный ключ получает **400** с субъектом +сертификата и статусами отзыва. + +Вердикт выносится теми же средствами, что и верификация +(`attachValidationData` + `isValid`), поэтому совпадает с тем, что потом скажет +`/{cms,xml,pdf}/verify` о готовой подписи. Криптопроверку сертификата ключом УЦ +(подпункт 1) обеспечивает поиск издателя в CA-бандле: `getRootCertificateFor` +принимает издателя, только если подпись сертификата сходится с его ключом. + +**Выключено по умолчанию** (`NCANODE_SIGN_CERT_CHECK`, согласовано): включение +меняет поведение всех sign-эндпойнтов — просроченный или отозванный ключ +перестаёт подписывать — и добавляет обращение к OCSP на каждое подписание. +Развёртывание, обязанное соответствовать Правилам, включает флаг. + +## Наблюдение по тестам + +`AdesReferenceCompatibilityTest` проверяет доступность `pki.gov.kz` один раз на +старте спеки. В одном из прогонов боевая PKI замолчала **в середине** прогона — +все 15 эталонов покраснели, хотя код ни при чём (повторный прогон: 15/15 +зелёные). Признак тот же, что в quirk #44, но защита от него односторонняя: +проверять доступность имеет смысл не только на входе. diff --git a/src/main/kotlin/kz/ncanode/ades/CadesInspector.kt b/src/main/kotlin/kz/ncanode/ades/CadesInspector.kt index 71ce6440..2344a1ee 100644 --- a/src/main/kotlin/kz/ncanode/ades/CadesInspector.kt +++ b/src/main/kotlin/kz/ncanode/ades/CadesInspector.kt @@ -105,8 +105,13 @@ object CadesInspector { * её к конкретному сертификату. Не проверять его — значит принимать * заявленный уровень B, не подтверждая единственное, что этот уровень * добавляет. + * + * Публичный: тем же атрибутом обязан быть снабжён любой CAdES (п. 8 + * приказа МИИ РК №500/НҚ), поэтому привязку сверяет и обычный + * `/cms/verify` — CMS с атрибутом, указывающим на другой сертификат, + * внутренне противоречив, каким бы путём он ни пришёл. */ - private fun signingCertificateMatches( + fun signingCertificateMatches( signer: SignerInformation, certificate: X509Certificate?, provider: Provider, diff --git a/src/main/kotlin/kz/ncanode/configuration/SignConfiguration.kt b/src/main/kotlin/kz/ncanode/configuration/SignConfiguration.kt new file mode 100644 index 00000000..d6fd06cc --- /dev/null +++ b/src/main/kotlin/kz/ncanode/configuration/SignConfiguration.kt @@ -0,0 +1,28 @@ +package kz.ncanode.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +/** + * Политика подписания (`ncanode.sign.*`). + */ +@Configuration +@ConfigurationProperties(prefix = "ncanode.sign") +open class SignConfiguration { + + /** + * Проверять сертификат подписанта ПЕРЕД формированием подписи — + * п. 4 Правил формирования и проверки подлинности ЭЦП (приказ МИИ РК + * №500/НҚ): подпись УЦ, срок действия, отсутствие отзыва (OCSP, при его + * недоступности — CRL) и допустимость назначения ключа. + * + * Выключено по умолчанию: включение меняет поведение всех sign-эндпойнтов + * (просроченный или отозванный ключ начинает получать отказ 400 вместо + * подписи) и добавляет обращение к OCSP на каждое подписание. Развёртывание, + * которое обязано соответствовать Правилам, включает это флагом + * `NCANODE_SIGN_CERT_CHECK=true`; проверка выполняется теми же средствами, + * что и верификация, поэтому её вердикт совпадает с тем, что потом скажет + * `/{cms,xml,pdf}/verify`. + */ + var isCertificateCheck: Boolean = false +} diff --git a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateInfo.kt b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateInfo.kt index 35c2635b..85c39ca8 100644 --- a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateInfo.kt +++ b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateInfo.kt @@ -13,6 +13,14 @@ data class CertificateInfo( val serialNumber: String? = null, val signAlg: String? = null, val keyUser: Set? = null, + /** + * OID'ы политик применения сертификата (расширение `certificatePolicies`, + * 2.5.29.32). Публикуются, чтобы проверяющая сторона могла сверить номер + * политики с условиями её применения — п. 16 Правил формирования и + * проверки подлинности ЭЦП (приказ МИИ РК №500/НҚ). Сами условия задаёт + * политика УЦ и из сертификата не выводятся, поэтому мы их не enforce'им. + */ + val policies: List? = null, val publicKey: String? = null, val signature: String? = null, val subject: CertificateSubject? = null, diff --git a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateKeyUser.kt b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateKeyUser.kt index fa8302c8..73d675da 100644 --- a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateKeyUser.kt +++ b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateKeyUser.kt @@ -2,12 +2,18 @@ package kz.ncanode.dto.certificate enum class CertificateKeyUser(val oid: String) { INDIVIDUAL("1.2.398.3.3.4.1.1"), + /** Шаблон «цифровая система физического лица» (приказ №522/НҚ). */ + INDIVIDUAL_DIGITAL_SYSTEM("1.2.398.3.3.4.1.1.1"), ORGANIZATION("1.2.398.3.3.4.1.2"), CEO("1.2.398.3.3.4.1.2.1"), CAN_SIGN("1.2.398.3.3.4.1.2.2"), CAN_SIGN_FINANCIAL("1.2.398.3.3.4.1.2.3"), HR("1.2.398.3.3.4.1.2.4"), EMPLOYEE("1.2.398.3.3.4.1.2.5"), + /** Шаблон «цифровая система юридического лица» (приказ №522/НҚ). */ + ORGANIZATION_DIGITAL_SYSTEM("1.2.398.3.3.4.1.2.6"), + /** Шаблон «участник цифровой системы "Казначейство – Клиент"». */ + TREASURY_CLIENT("1.2.398.5.19.1.2.2.1"), NCA_PRIVILEGES("1.2.398.3.3.4.2"), NCA_ADMIN("1.2.398.3.3.4.2.1"), NCA_MANAGER("1.2.398.3.3.4.2.2"), diff --git a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateSubject.kt b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateSubject.kt index a247b9a7..be3d5322 100644 --- a/src/main/kotlin/kz/ncanode/dto/certificate/CertificateSubject.kt +++ b/src/main/kotlin/kz/ncanode/dto/certificate/CertificateSubject.kt @@ -14,5 +14,22 @@ data class CertificateSubject( val country: String? = null, val locality: String? = null, val state: String? = null, + /** + * UID (0.9.2342.19200300.100.1.1). У сертификатов шаблона «цифровая + * система юридического лица» (приказ МИИ РК №522/НҚ) здесь OID самой + * цифровой системы, в которую разрешено ставить закрытый ключ. + */ + val uid: String? = null, + /** + * businessCategory (2.5.4.15). В шаблоне «участник цифровой системы + * "Казначейство – Клиент"» (приказ МИИ РК №522/НҚ, приложение 3, + * структура 6) это обязательное поле с кодом клиента вида `KS01234`. + */ + val businessCategory: String? = null, + /** + * domainComponent (0.9.2342.19200300.100.1.25). В том же шаблоне — + * обязательное поле с ролью участника вида `ROLE01`. + */ + val domainComponent: String? = null, val dn: String? = null, ) diff --git a/src/main/kotlin/kz/ncanode/dto/certificate/RevocationResult.kt b/src/main/kotlin/kz/ncanode/dto/certificate/RevocationResult.kt index 7da9c5e1..1e7aebac 100644 --- a/src/main/kotlin/kz/ncanode/dto/certificate/RevocationResult.kt +++ b/src/main/kotlin/kz/ncanode/dto/certificate/RevocationResult.kt @@ -29,4 +29,11 @@ enum class RevocationResult { * по данным другого канала. */ UNAVAILABLE, + + /** + * CRL издателя найден и проверен, но его период действия истёк на момент + * проверки (п. 18 приказа МИИ РК №500/НҚ). Отзыва в нём нет, однако об + * отзывах после `nextUpdate` он не свидетельствует. + */ + EXPIRED, } diff --git a/src/main/kotlin/kz/ncanode/dto/crl/CrlResult.kt b/src/main/kotlin/kz/ncanode/dto/crl/CrlResult.kt index d49da54a..e7995757 100644 --- a/src/main/kotlin/kz/ncanode/dto/crl/CrlResult.kt +++ b/src/main/kotlin/kz/ncanode/dto/crl/CrlResult.kt @@ -6,6 +6,19 @@ enum class CrlResult { /** Серийник не найден ни в одном пригодном CRL издателя. */ ACTIVE, + /** + * Список издателя найден и криптографически проверен, серийника в нём нет, + * но период действия списка на момент проверки истёк (`nextUpdate` в + * прошлом либо отсутствует). + * + * Отдельный исход, а не ACTIVE: п. 18 Правил формирования и проверки + * подлинности ЭЦП (приказ МИИ РК №500/НҚ) прямо называет истечение периода + * действия CRL отрицательным результатом его проверки. Отсутствие + * серийника в протухшем списке ничего не говорит об отзывах, случившихся + * после `nextUpdate`. + */ + EXPIRED, + /** * Проверить было нечем: CRL выключен конфигом, ни один CRL издателя * не найден в кэше, либо все найденные отброшены (битая подпись, diff --git a/src/main/kotlin/kz/ncanode/dto/crl/CrlStatus.kt b/src/main/kotlin/kz/ncanode/dto/crl/CrlStatus.kt index ab1ec09c..f87783a2 100644 --- a/src/main/kotlin/kz/ncanode/dto/crl/CrlStatus.kt +++ b/src/main/kotlin/kz/ncanode/dto/crl/CrlStatus.kt @@ -25,9 +25,10 @@ data class CrlStatus( * по данным этого CRL (CAdES-T). * * `ACTIVE` — да. `REVOKED` — да только если отзыв произошёл строго после - * [signingTime] и по benign-причине (см. [RevocationPolicy]); `UNAVAILABLE` - * и отсутствие даты/причины отзыва — консервативно нет (нефатальность - * UNAVAILABLE в AND-режиме решается в `CertificateWrapper.isValid`). + * [signingTime] и по benign-причине (см. [RevocationPolicy]); `UNAVAILABLE`, + * `EXPIRED` и отсутствие даты/причины отзыва — консервативно нет + * (нефатальность UNAVAILABLE и условия для EXPIRED разбираются в + * `CertificateWrapper.isValid`). */ fun isValidAt(signingTime: Date): Boolean = when (result) { CrlResult.ACTIVE -> true @@ -48,6 +49,7 @@ data class CrlStatus( CrlResult.ACTIVE -> RevocationResult.ACTIVE CrlResult.REVOKED -> RevocationResult.REVOKED CrlResult.UNAVAILABLE -> RevocationResult.UNAVAILABLE + CrlResult.EXPIRED -> RevocationResult.EXPIRED else -> RevocationResult.UNKNOWN }, ) diff --git a/src/main/kotlin/kz/ncanode/service/CadesService.kt b/src/main/kotlin/kz/ncanode/service/CadesService.kt index 3f8a2231..cc741110 100644 --- a/src/main/kotlin/kz/ncanode/service/CadesService.kt +++ b/src/main/kotlin/kz/ncanode/service/CadesService.kt @@ -61,6 +61,7 @@ class CadesService( private val tspService: TspService, private val cmsService: CmsService, private val validationDataService: ValidationDataService, + private val certificateService: CertificateService, ) { fun sign(request: CadesSignRequest): CadesResponse { @@ -75,6 +76,8 @@ class CadesService( for (keyStore in kalkanWrapper.read(request.signers)) { val certificate = keyStore.certificate.x509Certificate + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStore.certificate) addSigner(generator, keyStore.privateKey, certificate) certificates.add(certificate) } @@ -151,6 +154,8 @@ class CadesService( val existingSignerCertificates = signerCertificates(existing) for (keyStore in kalkanWrapper.read(request.signers)) { val certificate = keyStore.certificate.x509Certificate + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStore.certificate) warnIfSignsAgain(existingSignerCertificates, certificate, request.level) addSigner(generator, keyStore.privateKey, certificate) certificates.add(certificate) diff --git a/src/main/kotlin/kz/ncanode/service/CertificateService.kt b/src/main/kotlin/kz/ncanode/service/CertificateService.kt index fa5249e3..58768cba 100644 --- a/src/main/kotlin/kz/ncanode/service/CertificateService.kt +++ b/src/main/kotlin/kz/ncanode/service/CertificateService.kt @@ -2,6 +2,7 @@ package kz.ncanode.service import kz.gov.pki.kalkan.jce.provider.KalkanProvider import kz.ncanode.configuration.OcspConfiguration +import kz.ncanode.configuration.SignConfiguration import kz.ncanode.constants.MessageConstants import kz.ncanode.dto.certificate.CertificateInfo import kz.ncanode.dto.request.Pkcs12AliasesBatchRequest @@ -49,6 +50,7 @@ class CertificateService( private val caService: CaService, private val kalkanWrapper: KalkanWrapper, private val ocspConfiguration: OcspConfiguration, + private val signConfiguration: SignConfiguration, ) { /** @@ -69,6 +71,42 @@ class CertificateService( } } + /** + * Проверка сертификата подписанта ПЕРЕД формированием подписи — п. 4 + * Правил формирования и проверки подлинности ЭЦП (приказ МИИ РК №500/НҚ): + * подпись удостоверяющего центра, срок действия, отсутствие отзыва (OCSP, + * при его недоступности — CRL) и допустимость назначения ключа. + * + * Выключена по умолчанию (`NCANODE_SIGN_CERT_CHECK`), потому что меняет + * поведение всех sign-эндпойнтов и добавляет обращение к OCSP на каждое + * подписание. Когда включена — отказ клиенту (400): подписывать + * просроченным или отозванным ключом бессмысленно, такая подпись всё равно + * не пройдёт проверку. + * + * Вердикт выносится теми же средствами, что и при верификации + * ([attachValidationData] + [CertificateWrapper.isValid]), поэтому + * совпадает с тем, что потом скажет `/{cms,xml,pdf}/verify` о готовой + * подписи. Криптопроверку сертификата ключом УЦ (п. 4, подпункт 1) делает + * поиск издателя в CA-бандле: `CaService.getRootCertificateFor` принимает + * издателя, только если подпись сертификата сходится с его ключом. + */ + fun ensureSignerCertificateUsable(certificate: CertificateWrapper) { + if (!signConfiguration.isCertificateCheck) return + + val now = Date() + attachValidationData(certificate, checkOcsp = true, checkCrl = true) + if (certificate.isValid(now, checkOcsp = true, checkCrl = true, requireSigningKeyUsage = true)) return + + val revocations = certificate.toCertificateInfo(now, checkOcsp = true, checkCrl = true) + .revocations.orEmpty() + .joinToString(", ") { "${it.by}=${it.result}" } + throw ClientException( + "Signer certificate ${certificate.subjectX500Principal} cannot be used for signing" + + (if (revocations.isEmpty()) "" else " (revocation status: $revocations)") + + ". See the log for the exact reason; the check itself is enabled by NCANODE_SIGN_CERT_CHECK.", + ) + } + /** * Пакетный prefetch валидационных данных для списка сертификатов. * @@ -306,7 +344,10 @@ class CertificateService( attachValidationData(cert, checkOcsp, checkCrl) - if (!cert.isValid(currentDate, checkOcsp, checkCrl)) { + // Здесь проверяется подпись, значит назначение ключа обязано её + // допускать (п. 16 Правил №500/НҚ) — в отличие от `verifyCerts` + // и `info` выше, которые лишь описывают сертификат. + if (!cert.isValid(currentDate, checkOcsp, checkCrl, requireSigningKeyUsage = true)) { valid = false } @@ -335,6 +376,7 @@ class CertificateService( fun sign(request: SbaSignRequest): SbaSignResponse { val signer = request.signer ?: throw ClientException("signer must be specified") val keyStore = kalkanWrapper.read(listOf(signer))[0] + ensureSignerCertificateUsable(keyStore.certificate) return SbaSignResponse( certificate = Base64.getEncoder().encodeToString(keyStore.certificate.x509Certificate.encoded), @@ -349,6 +391,7 @@ class CertificateService( fun signBatch(request: SbaSignBatchRequest): SbaSignBatchResponse { val signer = request.signer ?: throw ClientException("signer must be specified") val keyStore = kalkanWrapper.read(listOf(signer))[0] + ensureSignerCertificateUsable(keyStore.certificate) val items = request.data.mapPartial({ status, message -> SbaSignBatchResponse.Item(status = status, message = message) diff --git a/src/main/kotlin/kz/ncanode/service/CmsService.kt b/src/main/kotlin/kz/ncanode/service/CmsService.kt index 954f9e4b..19c990a6 100644 --- a/src/main/kotlin/kz/ncanode/service/CmsService.kt +++ b/src/main/kotlin/kz/ncanode/service/CmsService.kt @@ -8,6 +8,7 @@ import kz.gov.pki.kalkan.jce.provider.cms.CMSSignedDataGenerator import kz.gov.pki.kalkan.jce.provider.cms.SignerInformation import kz.gov.pki.kalkan.jce.provider.cms.SignerInformationStore import kz.gov.pki.kalkan.util.encoders.Hex +import kz.ncanode.ades.CadesInspector import kz.ncanode.dto.certificate.CertificateInfo import kz.ncanode.dto.cms.CmsSignerInfo import kz.ncanode.dto.request.CmsCreateBatchRequest @@ -377,8 +378,17 @@ class CmsService( // attachValidationData идемпотентен: prefetch уже сделал тяжёлую часть // (OCSP параллельно, CRL с кэшем), здесь только issuer если он null. certificateService.attachValidationData(cert, checkOcsp, checkCrl) + // signingCertificateV2 лежит под подписью и привязывает её к + // конкретному сертификату (RFC 5035). Атрибут обязателен для CAdES + // (п. 8 приказа МИИ РК №500/НҚ), и раз он есть — расхождение с + // сертификатом означает внутренне противоречивый контейнер, + // независимо от того, каким эндпойнтом его проверяют. + val bindingMatches = CadesInspector.signingCertificateMatches( + signer, cert.x509Certificate, kalkanWrapper.kalkanProvider, + ) if (!signer.verify(cert.publicKey, KalkanProvider.PROVIDER_NAME) - || !cert.isValid(validationDate, checkOcsp, checkCrl) + || !bindingMatches + || !cert.isValid(validationDate, checkOcsp, checkCrl, requireSigningKeyUsage = true) ) { valid = false } @@ -464,6 +474,8 @@ class CmsService( try { for (ks in kalkanWrapper.read(signers)) { val cert = ks.certificate + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(cert) val privateKey = ks.privateKey val x509 = cert.x509Certificate diff --git a/src/main/kotlin/kz/ncanode/service/CrlService.kt b/src/main/kotlin/kz/ncanode/service/CrlService.kt index b3eefb47..6f113557 100644 --- a/src/main/kotlin/kz/ncanode/service/CrlService.kt +++ b/src/main/kotlin/kz/ncanode/service/CrlService.kt @@ -71,6 +71,15 @@ open class CrlService( */ private fun cacheOnDemandDir() = "crl/$crlServiceType/ondemand" + /** + * Кэш для разностных (delta) CRL, скачанных по `freshestCRL` из самого + * сертификата. Отдельный каталог, а не общий с [cacheOnDemandDir], потому + * что провенанс списка — единственное, что защищает отбор base от delta, + * в которой нет `deltaCRLIndicator` (см. [selectCrls]). Оба on-demand + * каталога живут под одним потолком [enforceOnDemandLimit]. + */ + private fun cacheOnDemandDeltaDir() = "crl/$crlServiceType/ondemand-delta" + /** * Кэш открытых CRL-индексов. * @@ -152,7 +161,7 @@ open class CrlService( var sigVerified = 0 var errors = 0 - for (dir in listOf(cacheFullDir(), cacheDeltaDir(), cacheOnDemandDir())) { + for (dir in listOf(cacheFullDir(), cacheDeltaDir(), cacheOnDemandDir(), cacheOnDemandDeltaDir())) { for (crlFile in getCrlFiles(dir)) { try { val index = loadCachedIndex(crlFile) @@ -264,6 +273,13 @@ open class CrlService( val fresh: Boolean, /** Список свидетельствует о состоянии на момент проверки — см. [authoritativeAt]. */ val authoritative: Boolean = fresh, + /** + * Файл скачан с delta-эндпоинта (`crl.delta.url`). Провенанс, а не + * содержимое: он говорит, чем список задумывался, даже если разбор не + * нашёл в нём `deltaCRLIndicator`. Такой список нельзя брать как base + * (см. [selectCrls]). + */ + val fromDeltaEndpoint: Boolean = false, ) { val isDelta: Boolean get() = baseCrlNumber != null } @@ -325,7 +341,22 @@ open class CrlService( ) } - return verdict(base, delta, cert) + val status = verdict(base, delta, cert) + // п. 18 Правил (приказ №500/НҚ): истёкший период действия CRL — + // отрицательный результат его проверки. Отсутствие серийника в + // протухшем списке не свидетельствует о том, что отзыва не было: + // всё, что издатель опубликовал после `nextUpdate`, в нём отсутствует + // по определению. Отзыв, наоборот, остаётся в силе (REVOKED не + // трогаем — отзывы не отменяются). + return if (status.result == CrlResult.ACTIVE && !status.fresh) { + CrlStatus( + result = CrlResult.EXPIRED, + file = status.file, + reason = "CRL validity period has ended (nextUpdate is in the past or absent)", + ) + } else { + status + } } /** @@ -371,20 +402,39 @@ open class CrlService( ): Pair { val usable = ArrayList() val onDemandDir = cacheOnDemandDir() - for (cacheDirectory in listOf(cacheFullDir(), cacheDeltaDir(), onDemandDir)) { + val onDemandDeltaDir = cacheOnDemandDeltaDir() + val deltaDir = cacheDeltaDir() + val deltaEndpointDirs = setOf(deltaDir, onDemandDeltaDir) + val onDemandDirs = setOf(onDemandDir, onDemandDeltaDir) + for (cacheDirectory in listOf(cacheFullDir(), deltaDir, onDemandDir, onDemandDeltaDir)) { for (crlFile in getCrlFiles(cacheDirectory)) { loadUsableCrl(crlFile, certIssuer, issuerKey, now)?.let { // Отмечаем именно пригодившийся CRL: тот, что не подошёл ни // одному издателю, так и остаётся кандидатом на вытеснение. - if (cacheDirectory == onDemandDir) { + if (cacheDirectory in onDemandDirs) { onDemandLastUse[crlFile.absolutePath] = System.currentTimeMillis() } - usable.add(it) + usable.add(it.copy(fromDeltaEndpoint = cacheDirectory in deltaEndpointDirs)) } } } - val base = usable.filter { !it.isDelta }.maxWithOrNull(compareBy { it.crlNumber }) + // Base ищем только среди списков, которые не пришли с delta-эндпоинта. + // Иначе delta без `deltaCRLIndicator` (профиль разностного СОС в + // приказе №522/НҚ маркирует её `freshestCRL`, а не индикатором) попала + // бы в пул base и выиграла бы отбор: её CRLNumber на порядки больше + // (на бою 57 725 против 1 346 у полного). Вердикт считался бы по + // одной delta, и всё, что отозвано только в полном списке, вернулось + // бы как ACTIVE. + val baseCandidates = usable.filter { !it.isDelta && !it.fromDeltaEndpoint } + usable.filter { !it.isDelta && it.fromDeltaEndpoint }.forEach { + log.warn( + "CRL {} came from the delta endpoint but carries no deltaCRLIndicator — " + + "not usable as a base list, and not orderable as a delta", + it.fileName, + ) + } + val base = baseCandidates.maxWithOrNull(compareBy { it.crlNumber }) ?: return null to null val delta = usable .filter { it.isDelta && isDeltaApplicable(base, it) } @@ -739,10 +789,21 @@ open class CrlService( } /** - * Скачивает CRL'и, указанные в `cRLDistributionPoints` cert'а, если они - * ещё не лежат в кэше или протухли по TTL. Тихий метод — упавший - * download не пробрасывает наружу (есть логирование внутри downloadCrl), - * verify() в любом случае попробует использовать имеющийся кэш. + * Скачивает CRL'и, на которые ссылается сам сертификат, если они ещё не + * лежат в кэше или протухли по TTL. Тихий метод — упавший download не + * пробрасывает наружу (есть логирование внутри downloadCrl), verify() в + * любом случае попробует использовать имеющийся кэш. + * + * Берутся оба расширения: `cRLDistributionPoints` (полный список) и + * `freshestCRL` (разностный). Второе до сих пор не читалось, а НУЦ + * публикует адрес delta именно там — без него для издателя вне конфига мы + * работали на одном полном списке и не видели досрочных отзывов. + * + * Внутри одной точки распространения адреса — зеркала одного и того же + * списка (RFC 5280 §4.2.1.13), поэтому качаем по первому сработавшему. + * Профили из приказа №522/НҚ объявляют пару `crl.pki.gov.kz` + + * `crl1.pki.gov.kz`: без этого мы держали бы в кэше два экземпляра одного + * 20-МБ списка и оба индексировали. * * URL фильтруются по схеме (только http/https) — defense-in-depth против * SSRF через подконтрольный атакующему cert. @@ -753,42 +814,53 @@ open class CrlService( // скачать CRL с произвольного (внутреннего) URL. if (crlConfiguration.isStrict) return - val crlUrls = cert.crlList - if (crlUrls.isEmpty()) return + // Полные списки и разностные складываем в РАЗНЫЕ каталоги: каталог — + // единственный носитель провенанса, а на нём держится защита отбора + // base (см. selectCrls). Delta, попавшая в общий on-demand каталог, + // выглядела бы обычным списком и, не имея deltaCRLIndicator, выиграла + // бы отбор по CRLNumber. + val distributionPoints = cert.crlDistributionPoints.map { it to cacheOnDemandDir() } + + cert.freshestCrlDistributionPoints.map { it to cacheOnDemandDeltaDir() } + if (distributionPoints.isEmpty()) return val ttl = crlConfiguration.ttl ?: return val ttlMillis = ttl.toLong() * 60_000L val now = System.currentTimeMillis() - val dirName = cacheOnDemandDir() - val cacheDir = directoryService.getCachePathFor(dirName) ?: return var fetched = false - for (url in crlUrls) { - // Минимальный SSRF-барьер: URL из серта не должен указывать на - // loopback/link-local (cloud-metadata) — см. isInternalHost. - if (!isAllowedCrlScheme(url) || isInternalHost(url)) continue - val fileName = sha1(url.toString()) + CRL_FILE_EXTENSION - - // Дедуп: если URL уже покрывается scheduled-flow'ом (т.е. файл - // уже есть в config-кэше full или delta), не качаем дубликат - // в ondemand. Reuse того же файла, что обновляет scheduled-job - // — экономит диск и убирает удвоенную работу при verify. - if (isAlreadyInConfigCache(fileName)) { - log.debug("CRL URL already covered by config cache, skipping on-demand: {}", url) - continue - } + for ((mirrors, dirName) in distributionPoints) { + val cacheDir = directoryService.getCachePathFor(dirName) ?: continue + for (url in mirrors) { + // Минимальный SSRF-барьер: URL из серта не должен указывать на + // loopback/link-local (cloud-metadata) — см. isInternalHost. + if (!isAllowedCrlScheme(url) || isInternalHost(url)) continue + val fileName = sha1(url.toString()) + CRL_FILE_EXTENSION + + // Дедуп: если URL уже покрывается scheduled-flow'ом (т.е. файл + // уже есть в config-кэше full или delta), не качаем дубликат + // в ondemand. Reuse того же файла, что обновляет scheduled-job + // — экономит диск и убирает удвоенную работу при verify. + if (isAlreadyInConfigCache(fileName)) { + log.debug("CRL URL already covered by config cache, skipping on-demand: {}", url) + break + } - val crlFile = File(cacheDir, fileName) - val stale = !crlFile.exists() - || !crlFile.isFile - || !crlFile.canRead() - || (now - crlFile.lastModified()) > ttlMillis + val crlFile = File(cacheDir, fileName) + val stale = !crlFile.exists() + || !crlFile.isFile + || !crlFile.canRead() + || (now - crlFile.lastModified()) > ttlMillis - if (!stale) continue + // Свежая копия этой точки распространения уже есть — зеркала + // не трогаем. + if (!stale) break - log.debug("On-demand fetching CRL from cert CRL-DP: {}", url) - downloadCrl(dirName, url) - fetched = true + log.debug("On-demand fetching CRL from cert CRL-DP: {}", url) + if (tryDownloadCrl(dirName, url)) { + fetched = true + break + } + } } // Кэш пополняется URL'ами из присланных сертификатов, то есть растёт @@ -815,7 +887,9 @@ open class CrlService( val limit = crlConfiguration.onDemandMaxEntries if (limit <= 0) return - val files = getCrlFiles(cacheOnDemandDir()) + // Потолок общий на оба on-demand каталога: это один кэш, просто + // разложенный по провенансу. + val files = getCrlFiles(cacheOnDemandDir()) + getCrlFiles(cacheOnDemandDeltaDir()) // Подчищаем хвосты учёта от файлов, которых уже нет на диске. val present = files.mapTo(HashSet()) { it.absolutePath } @@ -891,11 +965,20 @@ open class CrlService( * из сертификата, которая идёт внутри проверки подписи. */ fun downloadCrl(cacheDirName: String, url: URL) { - try { - downloadCrlOrThrow(cacheDirName, url) - } catch (e: CrlException) { - log.error("CRL File download failure", e.cause) - } + tryDownloadCrl(cacheDirName, url) + } + + /** + * То же, но с ответом «получилось ли». Нужно тем, кто перебирает зеркала + * одной точки распространения: следующий адрес имеет смысл пробовать + * только после неудачи предыдущего. + */ + private fun tryDownloadCrl(cacheDirName: String, url: URL): Boolean = try { + downloadCrlOrThrow(cacheDirName, url) + true + } catch (e: CrlException) { + log.error("CRL File download failure", e.cause) + false } /** @@ -1067,10 +1150,22 @@ open class CrlService( private const val MEM_CACHE_MAX_ENTRIES = 256 /** - * Единственное critical CRL-расширение, которое мы обрабатываем: - * deltaCRLIndicator (2.5.29.27) — маркер delta-CRL. Любое другое - * critical-расширение дисквалифицирует CRL (RFC 5280 §5.2). + * Critical CRL-расширения, с которыми список остаётся пригодным. + * Любое другое critical-расширение дисквалифицирует CRL (RFC 5280 §5.2). + * + * - `deltaCRLIndicator` (2.5.29.27) — маркер delta-CRL, мы его + * обрабатываем (наложение поверх base, см. [verify]); + * - `freshestCRL` (2.5.29.46) — указатель на адрес delta-списка. + * RFC 5280 §5.2.6 требует его НЕкритичным, но профили разностных + * СОС в приказе МИИ РК №522/НҚ (приложение 3, структуры 13 и 14) + * объявляют его critical. Игнорировать указатель безопасно: он не + * сужает охват списка, а лишь говорит, где искать более свежий. + * Дисквалифицировать из-за него CRL — значит остаться вообще без + * delta, то есть без досрочных отзывов. */ - private val SUPPORTED_CRITICAL_CRL_EXTENSIONS = setOf(Extension.deltaCRLIndicator.id) + private val SUPPORTED_CRITICAL_CRL_EXTENSIONS = setOf( + Extension.deltaCRLIndicator.id, + Extension.freshestCRL.id, + ) } } diff --git a/src/main/kotlin/kz/ncanode/service/JwsService.kt b/src/main/kotlin/kz/ncanode/service/JwsService.kt index 4821d728..8ccfe9a2 100644 --- a/src/main/kotlin/kz/ncanode/service/JwsService.kt +++ b/src/main/kotlin/kz/ncanode/service/JwsService.kt @@ -132,6 +132,8 @@ class JwsService( }, ), )[0] + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStore.certificate) val certificate = keyStore.certificate.x509Certificate val header = mapper.createObjectNode().apply { @@ -183,7 +185,7 @@ class JwsService( val currentDate = certificateService.getCurrentDate() val signatureValid = signatureVerifies(alg, certificate, headerEncoded, payloadEncoded, entry) && - wrapper.isValid(currentDate, checkOcsp, checkCrl) + wrapper.isValid(currentDate, checkOcsp, checkCrl, requireSigningKeyUsage = true) return JwsSignerInfo( valid = signatureValid, diff --git a/src/main/kotlin/kz/ncanode/service/JwtService.kt b/src/main/kotlin/kz/ncanode/service/JwtService.kt index ac308a0f..d53ed720 100644 --- a/src/main/kotlin/kz/ncanode/service/JwtService.kt +++ b/src/main/kotlin/kz/ncanode/service/JwtService.kt @@ -38,7 +38,10 @@ import java.util.Base64 * Сервис отвечает за всё что связано с JWT. */ @Service -class JwtService(private val kalkanWrapper: KalkanWrapper) { +class JwtService( + private val kalkanWrapper: KalkanWrapper, + private val certificateService: CertificateService, +) { /** * Формирование и подписание JWT. @@ -51,6 +54,8 @@ class JwtService(private val kalkanWrapper: KalkanWrapper) { jwtEncodeRequest.password, ) val cert = keystore.certificate + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(cert) val builder = JWT.create() for ((claimKey, claimValue) in jwtEncodeRequest.jwt.payload.claims) { diff --git a/src/main/kotlin/kz/ncanode/service/OcspService.kt b/src/main/kotlin/kz/ncanode/service/OcspService.kt index 06689058..b3725160 100644 --- a/src/main/kotlin/kz/ncanode/service/OcspService.kt +++ b/src/main/kotlin/kz/ncanode/service/OcspService.kt @@ -99,35 +99,45 @@ class OcspService( private fun exchanges(cert: CertificateWrapper, issuer: CertificateWrapper): List { val exchanges = mutableListOf() for (ocspUrl in resolveOcspUrls(cert)) { - val url = ocspUrl.toString() - try { - val nonce = generateOcspNonce() - val request = buildOcspRequest(cert.x509Certificate.serialNumber, issuer.x509Certificate, nonce) - - val response = makeRequest(url, request.encoded) - val status = processOcspResponse(response, nonce, issuer, cert.x509Certificate.serialNumber) - exchanges.add(OcspExchange(status.copy(url = url), response)) - } catch (e: IOException) { - // Транспортный сбой (сеть/DNS/таймаут) или unparseable body - // (OCSPResp тоже кидает IOException на мусоре) — OCSP-ответа - // НЕТ ВОВСЕ. Это UNAVAILABLE, а не UNKNOWN: при наличии свежего - // CRL верификация может деградировать на него (isValid). - exchanges.add(OcspExchange(unavailableStatus(url, e.message), null)) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - exchanges.add(OcspExchange(unavailableStatus(url, e.message), null)) - } catch (e: OCSPException) { - // Ответ был, но обработка/крипто не сошлись — fail-closed, - // деградация на CRL не допускается. - exchanges.add(OcspExchange(unknownStatus(url, e.message), null)) - } catch (e: GeneralSecurityException) { - exchanges.add(OcspExchange(unknownStatus(url, e.message), null)) - } + val exchange = exchange(ocspUrl.toString(), cert, issuer) + exchanges.add(exchange) + // Авторитетный ответ получен — остальные адреса не опрашиваем. + // Профили приказа МИИ РК №522/НҚ объявляют по два респондера + // (`ocsp` + `ocsp1.pki.gov.kz`), и это зеркала одного сервиса: + // второй запрос лишь удваивает нагрузку на НУЦ и время ответа. + // UNAVAILABLE авторитетным не считается — на нём как раз и + // переходим к следующему зеркалу. + if (exchange.status.result != OcspResult.UNAVAILABLE) break } return exchanges } + /** Один обмен с одним респондером. Исключений наружу не выпускает. */ + private fun exchange(url: String, cert: CertificateWrapper, issuer: CertificateWrapper): OcspExchange = try { + val nonce = generateOcspNonce() + val request = buildOcspRequest(cert.x509Certificate.serialNumber, issuer.x509Certificate, nonce) + + val response = makeRequest(url, request.encoded) + val status = processOcspResponse(response, nonce, issuer, cert.x509Certificate.serialNumber) + OcspExchange(status.copy(url = url), response) + } catch (e: IOException) { + // Транспортный сбой (сеть/DNS/таймаут) или unparseable body + // (OCSPResp тоже кидает IOException на мусоре) — OCSP-ответа + // НЕТ ВОВСЕ. Это UNAVAILABLE, а не UNKNOWN: при наличии свежего + // CRL верификация может деградировать на него (isValid). + OcspExchange(unavailableStatus(url, e.message), null) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + OcspExchange(unavailableStatus(url, e.message), null) + } catch (e: OCSPException) { + // Ответ был, но обработка/крипто не сошлись — fail-closed, + // деградация на CRL не допускается. + OcspExchange(unknownStatus(url, e.message), null) + } catch (e: GeneralSecurityException) { + OcspExchange(unknownStatus(url, e.message), null) + } + /** * Определяет, какие OCSP-URL'ы использовать для проверки этого cert'а. * Приоритет: AIA-extension cert'а → config fallback. @@ -196,10 +206,18 @@ class OcspService( issuer: CertificateWrapper, expectedSerial: BigInteger, ): OcspStatus { + // Пустое тело при HTTP 200 (перенаправление в никуда, оборванный + // ответ, прокси-заглушка) до разбора не доходит: `OCSPResp(ByteArray(0))` + // падает NPE — а он не ловится ни одной из наших веток и уходил бы + // наружу как 500 на всём verify. + if (response.isEmpty()) { + return unavailableStatus(url = null, message = "OCSP responder returned an empty body") + } + val resp = OCSPResp(response) - if (resp.status != 0) { - return unknownStatus(message = "OCSP response status: ${resp.status}") + if (resp.status != OCSP_RESPONSE_SUCCESSFUL) { + return unavailableStatus(url = null, message = ocspErrorMessage(resp.status)) } val brep = resp.responseObject as BasicOCSPResp @@ -322,7 +340,11 @@ class OcspService( certNotAfter: Date, ): OcspStatus? { val resp = OCSPResp(response) - if (resp.status != 0) return unknownStatus(message = "OCSP response status: ${'$'}{resp.status}") + // Ответ-ошибка не подписан и ничего не утверждает о сертификате — + // вшитый в подпись, он тем более не доказательство (см. processOcspResponse). + if (resp.status != OCSP_RESPONSE_SUCCESSFUL) { + return unavailableStatus(null, ocspErrorMessage(resp.status)) + } val brep = resp.responseObject as BasicOCSPResp findVerifiedResponderCertificate(brep, issuer) @@ -480,6 +502,24 @@ class OcspService( private fun unknownStatus(url: String? = null, message: String?): OcspStatus = OcspStatus(result = OcspResult.UNKNOWN, url = url, message = message) + /** + * Человекочитаемая расшифровка `OCSPResponseStatus` (RFC 6960 §4.2.1). + * В лог и в `revocations[].message` уходит имя, а не голое число: по + * `tryLater` сразу видно, что респондер жив и просит повторить, а по + * `unauthorized` — что запрос отвергнут. + */ + private fun ocspErrorMessage(status: Int): String { + val name = when (status) { + 1 -> "malformedRequest" + 2 -> "internalError" + 3 -> "tryLater" + 5 -> "sigRequired" + 6 -> "unauthorized" + else -> "unknown" + } + return "OCSP responder returned no answer: $name ($status)" + } + private fun unavailableStatus(url: String?, message: String?): OcspStatus = OcspStatus(result = OcspResult.UNAVAILABLE, url = url, message = message) @@ -489,6 +529,19 @@ class OcspService( /** RFC 8954 рекомендует nonce длиной не менее 16 байт. */ private const val NONCE_LENGTH = 16 + /** + * `OCSPResponseStatus.successful` (RFC 6960 §4.2.1). Всё остальное — + * ответ-ошибка: он НЕ подписан и не содержит статуса сертификата, + * то есть авторитетного ответа мы не получили. Поэтому такой исход — + * [OcspResult.UNAVAILABLE] (можно деградировать на свежий CRL), а не + * UNKNOWN (фатально). Разница практическая: приказ №522/НҚ обязывает + * НУЦ держать два респондера, и `tryLater` от перегруженного зеркала + * не должен объявлять валидную подпись недействительной. Плюс + * ответ-ошибку, раз она не подписана, может подделать кто угодно на + * пути — фатальной её делать нельзя. + */ + private const val OCSP_RESPONSE_SUCCESSFUL = 0 + /** * RFC 6960 §4.2.2.2: для делегированного OCSP-responder'а EKU должен * содержать id-kp-OCSPSigning. diff --git a/src/main/kotlin/kz/ncanode/service/PadesService.kt b/src/main/kotlin/kz/ncanode/service/PadesService.kt index 0414f860..d7ddc055 100644 --- a/src/main/kotlin/kz/ncanode/service/PadesService.kt +++ b/src/main/kotlin/kz/ncanode/service/PadesService.kt @@ -74,6 +74,7 @@ class PadesService( private val tspService: TspService, private val pdfService: PdfService, private val validationDataService: ValidationDataService, + private val certificateService: CertificateService, ) { fun sign(request: PadesSignRequest): PadesResponse { @@ -84,6 +85,8 @@ class PadesService( val keyStore = kalkanWrapper.read( listOf(signer.signer ?: throw ClientException("signer must be specified")), )[0] + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStore.certificate) signerCertificates.add(keyStore.certificate.x509Certificate) pdf = embedSignature(pdf, signer, keyStore, request.level, request.tsaPolicy) } diff --git a/src/main/kotlin/kz/ncanode/service/PdfService.kt b/src/main/kotlin/kz/ncanode/service/PdfService.kt index 20371a8b..26178939 100644 --- a/src/main/kotlin/kz/ncanode/service/PdfService.kt +++ b/src/main/kotlin/kz/ncanode/service/PdfService.kt @@ -64,6 +64,8 @@ class PdfService( val signerRequest = pdfSigner.signer ?: throw ClientException("signer must be specified") val keyStoreWrapper = kalkanWrapper.read(listOf(signerRequest))[0] + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStoreWrapper.certificate) val signature = PDSignature().apply { setFilter(PDSignature.FILTER_ADOBE_PPKLITE) @@ -375,7 +377,7 @@ class PdfService( // сертификатом: слою AdES не нужно выводить POE во второй раз. prepare(certificateWrapper, validationDate) certificateService.attachValidationData(certificateWrapper, withOcsp, withCrl) - if (!certificateWrapper.isValid(validationDate, withOcsp, withCrl)) { + if (!certificateWrapper.isValid(validationDate, withOcsp, withCrl, requireSigningKeyUsage = true)) { return PdfSignerAttempt(false, certificateWrapper, validationDate, digest = null) } diff --git a/src/main/kotlin/kz/ncanode/service/TspService.kt b/src/main/kotlin/kz/ncanode/service/TspService.kt index 3702ff6e..dbaa9bb8 100644 --- a/src/main/kotlin/kz/ncanode/service/TspService.kt +++ b/src/main/kotlin/kz/ncanode/service/TspService.kt @@ -240,7 +240,7 @@ class TspService( } certificateService.attachValidationData(tsaCert, checkOcsp, checkCrl) - if (!tsaCert.isValid(genTime, checkOcsp, checkCrl)) { + if (!tsaCert.isValid(genTime, checkOcsp, checkCrl, requireSigningKeyUsage = true)) { log.warn("TSA certificate is not valid at TSP genTime {}", genTime) return null } diff --git a/src/main/kotlin/kz/ncanode/service/WsseService.kt b/src/main/kotlin/kz/ncanode/service/WsseService.kt index 60e4ea33..ed9d967e 100644 --- a/src/main/kotlin/kz/ncanode/service/WsseService.kt +++ b/src/main/kotlin/kz/ncanode/service/WsseService.kt @@ -61,6 +61,8 @@ class WsseService( wsseSignRequest.password, ) val cert = keystore.certificate + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(cert) // sign a soap request according to a reference implementation from smartbridge val xmlBytes = xmlService.prepare(wsseSignRequest.xml, wsseSignRequest.isTrimXml) @@ -209,7 +211,7 @@ class WsseService( i, ) } - cryptoOk && coversBody && cert.isValid(currentDate, checkOcsp, checkCrl) + cryptoOk && coversBody && cert.isValid(currentDate, checkOcsp, checkCrl, requireSigningKeyUsage = true) } catch (e: XMLSecurityException) { log.warn("WSSE signature #{} verification failed: {}", i, e.message) false diff --git a/src/main/kotlin/kz/ncanode/service/XadesService.kt b/src/main/kotlin/kz/ncanode/service/XadesService.kt index a41384a7..4d6ccc1d 100644 --- a/src/main/kotlin/kz/ncanode/service/XadesService.kt +++ b/src/main/kotlin/kz/ncanode/service/XadesService.kt @@ -60,6 +60,7 @@ class XadesService( private val tspService: TspService, private val xmlService: XmlService, private val validationDataService: ValidationDataService, + private val certificateService: CertificateService, ) { /** @@ -86,6 +87,8 @@ class XadesService( val document = DocumentWrapper(request.xml) val keyStores = kalkanWrapper.read(request.signers) + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + keyStores.forEach { certificateService.ensureSignerCertificateUsable(it.certificate) } if (request.packaging == SignaturePackaging.ENVELOPING) { // Корень документа один, и им становится подпись — второй // подписи там просто негде разместиться. diff --git a/src/main/kotlin/kz/ncanode/service/XmlService.kt b/src/main/kotlin/kz/ncanode/service/XmlService.kt index 1fb7907c..7bf274a6 100644 --- a/src/main/kotlin/kz/ncanode/service/XmlService.kt +++ b/src/main/kotlin/kz/ncanode/service/XmlService.kt @@ -93,6 +93,8 @@ class XmlService( val signers = xmlSignRequest.signers kalkanWrapper.read(signers).forEachIndexed { i, keyStore -> + // п. 4 Правил №500/НҚ, если проверка включена конфигурацией. + certificateService.ensureSignerCertificateUsable(keyStore.certificate) document.createXmlSignature(keyStore.certificate, signers[i].referenceUri) .sign(keyStore.privateKey) } @@ -191,7 +193,7 @@ class XmlService( } val signatureValid = xmlSignature.check() && coversWhole && - cert.isValid(currentDate, checkOcsp, checkCrl) + cert.isValid(currentDate, checkOcsp, checkCrl, requireSigningKeyUsage = true) if (!signatureValid) { valid = false } diff --git a/src/main/kotlin/kz/ncanode/wrapper/CertificateWrapper.kt b/src/main/kotlin/kz/ncanode/wrapper/CertificateWrapper.kt index 2b65e39e..0c81865d 100644 --- a/src/main/kotlin/kz/ncanode/wrapper/CertificateWrapper.kt +++ b/src/main/kotlin/kz/ncanode/wrapper/CertificateWrapper.kt @@ -1,6 +1,8 @@ package kz.ncanode.wrapper +import kz.gov.pki.kalkan.asn1.ASN1Sequence import kz.gov.pki.kalkan.asn1.DERIA5String +import kz.gov.pki.kalkan.asn1.DERObjectIdentifier import kz.gov.pki.kalkan.asn1.x509.AccessDescription import kz.gov.pki.kalkan.asn1.x509.AuthorityInformationAccess import kz.gov.pki.kalkan.asn1.x509.CRLDistPoint @@ -78,6 +80,7 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { serialNumber = cert.serialNumber.toString(16), signAlg = cert.sigAlgName, keyUser = keyUser, + policies = certificatePolicies.ifEmpty { null }, publicKey = String(Base64.getEncoder().encode(cert.publicKey.encoded)), signature = String(Base64.getEncoder().encode(cert.signature)), subject = createCertificateSubjectFromDn(cert.subjectX500Principal.toString(), extractSanEmail(cert)), @@ -86,37 +89,93 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { } /** - * Получает список CRL DistributionPoint URL'ов сертификата. + * OID'ы политик применения сертификата (`certificatePolicies`, 2.5.29.32). + * + * Пустой список, если расширения нет или оно не разбирается. Условия + * применения политики задаёт УЦ, поэтому сверять номер с ними — дело + * проверяющей стороны (п. 16 приказа №500/НҚ); наше дело — показать его. */ - val crlList: List + val certificatePolicies: List get() { - val crlDistributionPoint = x509Certificate.getExtensionValue(Extension.cRLDistributionPoints.id) - ?: return emptyList() - + val encoded = x509Certificate.getExtensionValue(Extension.certificatePolicies.id) ?: return emptyList() return try { - val distPoint = CRLDistPoint.getInstance(X509ExtensionUtil.fromExtensionValue(crlDistributionPoint)) - val urls = mutableListOf() - for (dp in distPoint.distributionPoints) { - val dpn = dp.distributionPoint ?: continue - if (dpn.type != DistributionPointName.FULL_NAME) continue - val genNames = GeneralNames.getInstance(dpn.name).names - for (gn in genNames) { - if (gn.tagNo == GeneralName.uniformResourceIdentifier) { - urls.add(DERIA5String.getInstance(gn.name).string) - } - } + // Разбираем структуру напрямую: PolicyInformation ::= SEQUENCE { + // policyIdentifier OID, policyQualifiers OPTIONAL }. Хелперы + // Kalkan для этого расширения — из старой ветки BC, их API + // между версиями разъезжается сильнее, чем сама ASN.1-структура. + val policies = ASN1Sequence.getInstance(X509ExtensionUtil.fromExtensionValue(encoded)) + (0 until policies.size()).mapNotNull { i -> + val info = policies.getObjectAt(i) as? ASN1Sequence ?: return@mapNotNull null + (info.getObjectAt(0) as? DERObjectIdentifier)?.id } - urls.mapNotNull { createNewUrl(it, log) } } catch (e: Exception) { - // Битое/нестандартное cRLDistributionPoints не должно ронять - // verify в 500. Напр. URI-тег с не-IA5String → DERIA5String.getInstance - // кидает IllegalArgumentException (раньше был вне try → уходил в 500 - // на крафт-серте). Любой сбой парсинга → просто нет CRL-URL. - log.warn("Failed to parse cRLDistributionPoints extension: {}", e.message) + log.warn("Failed to parse certificatePolicies extension: {}", e.message) emptyList() } } + /** + * CRL DistributionPoint'ы сертификата, сгруппированные по точкам + * распространения: одна вложенная группа — один DistributionPoint. + * + * Группировка не косметическая. По RFC 5280 §4.2.1.13 несколько имён + * внутри одной точки — это адреса ОДНОГО И ТОГО ЖЕ списка, то есть + * зеркала: достаточно скачать по любому из них. Профили сертификатов из + * приказа МИИ РК №522/НҚ (в силе с 11.09.2026) как раз объявляют пару + * `crl.pki.gov.kz` + `crl1.pki.gov.kz`, и без группировки мы качали бы + * два экземпляра одного и того же 20-МБ списка. + */ + val crlDistributionPoints: List> + get() = distributionPointUrls(Extension.cRLDistributionPoints.id, "cRLDistributionPoints") + + /** + * Получает список CRL DistributionPoint URL'ов сертификата (плоский). + */ + val crlList: List + get() = crlDistributionPoints.flatten() + + /** + * Точки распространения разностного (delta) CRL — расширение `freshestCRL` + * (2.5.29.46, RFC 5280 §4.2.1.15). Синтаксис тот же, что у + * `cRLDistributionPoints`, а смысл другой: адрес delta-списка, покрывающего + * изменения после текущего полного CRL. + * + * Отдельное свойство, потому что до сих пор мы это расширение не читали, + * и delta прилетала только из конфигурации. Для издателя, которого нет в + * конфиге, мы работали на одном полном списке — то есть не видели + * досрочных отзывов, ради которых delta и публикуется. Сертификаты НУЦ + * (в том числе тестовые) кладут адрес delta именно сюда. + */ + val freshestCrlDistributionPoints: List> + get() = distributionPointUrls(Extension.freshestCRL.id, "freshestCRL") + + /** + * Разбирает расширение с синтаксисом `CRLDistributionPoints` в группы + * URL'ов — по группе на точку распространения. + */ + private fun distributionPointUrls(extensionOid: String, extensionName: String): List> { + val encoded = x509Certificate.getExtensionValue(extensionOid) ?: return emptyList() + + return try { + val distPoint = CRLDistPoint.getInstance(X509ExtensionUtil.fromExtensionValue(encoded)) + distPoint.distributionPoints.mapNotNull { dp -> + val dpn = dp.distributionPoint ?: return@mapNotNull null + if (dpn.type != DistributionPointName.FULL_NAME) return@mapNotNull null + GeneralNames.getInstance(dpn.name).names + .filter { it.tagNo == GeneralName.uniformResourceIdentifier } + .mapNotNull { createNewUrl(DERIA5String.getInstance(it.name).string, log) } + .ifEmpty { null } + } + } catch (e: Exception) { + // Битое/нестандартное расширение не должно ронять verify в 500. + // Напр. URI-тег с не-IA5String → DERIA5String.getInstance кидает + // IllegalArgumentException (раньше был вне try → уходил в 500 + // на крафт-серте). Любой сбой парсинга → просто нет CRL-URL. + log.warn("Failed to parse {} extension: {}", extensionName, e.message) + emptyList() + } + } + /** * Возвращает список OCSP-URL'ов, объявленных самим сертификатом в его * `authorityInfoAccess` extension'е (RFC 5280 §4.2.2.1, AccessMethod @@ -166,7 +225,12 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { * OCSP, которому нельзя верить ([OcspResult.UNKNOWN]), остаётся фатальным. * Деградация видна клиенту через `revocations[].result = UNAVAILABLE`. */ - fun isValid(date: Date, checkOcsp: Boolean, checkCrl: Boolean): Boolean { + fun isValid( + date: Date, + checkOcsp: Boolean, + checkCrl: Boolean, + requireSigningKeyUsage: Boolean = false, + ): Boolean { if (!isDateValid(date)) return false // RFC 5280 §4.2: сертификат с critical-расширением, которое мы не // обрабатываем, обязан отвергаться — иначе игнорировали бы ограничение, @@ -180,8 +244,26 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { log.warn("Certificate has unhandled critical extension(s) {} — rejecting (RFC 5280 §4.2)", unsupportedCritical) return false } + // п. 16 Правил формирования и проверки подлинности ЭЦП (приказ МИИ РК + // №500/НҚ): назначение ключа должно допускать подпись. Требование + // относится к сертификату ПОДПИСЫВАЮЩЕГО ЛИЦА, поэтому включается + // флагом: `/x509/info` и `/pkcs12/info` отвечают на другой вопрос — + // «что это за сертификат», и сертификат УЦ (keyUsage = keyCertSign, + // cRLSign) там не «недействителен». Отсутствие keyUsage — + // не ограничение (RFC 5280 §4.2.1.3, расширение опционально). + if (requireSigningKeyUsage && !permitsSignature()) { + log.warn( + "Certificate {} keyUsage permits neither digitalSignature nor nonRepudiation", + subjectX500Principal, + ) + return false + } val issuer = issuerCertificate ?: return false - if (!issuer.isDateValid(date)) return false + // п. 16 тех же Правил: срок действия проверяется у ВСЕЙ цепочки до + // доверенного корня — истечение любого сертификата в ней даёт + // отрицательный результат. Раньше проверялся только непосредственный + // издатель, и протухший промежуточный или корневой оставался незамеченным. + if (!isChainDateValid(issuer, date)) return false if (checkOcsp) { val statuses = ocspStatus ?: return false // Авторитетный плохой ответ хотя бы от одного responder'а — @@ -210,6 +292,23 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { } if (checkCrl) { val status = crlStatus ?: return false + // п. 18 Правил: истёкший CRL — отрицательный результат проверки + // отзыва. Не фатален он только тогда, когда отзыв уже проверен по + // второму каналу: п. 16 требует проверки «посредством сервиса OCSP + // либо CRL», и авторитетный ACTIVE от респондера — самостоятельный + // положительный результат. Иначе протухший список молча выдавал бы + // за «не отозван» всё, что издатель опубликовал после nextUpdate. + if (status.result == CrlResult.EXPIRED) { + val ocspAnswered = checkOcsp && ocspStatus?.any { it.result == OcspResult.ACTIVE } == true + if (!ocspAnswered) { + log.warn( + "CRL for {} is past its validity period and there is no OCSP verdict to rely on", + subjectX500Principal, + ) + return false + } + log.warn("CRL for {} is past its validity period; relying on the OCSP verdict", subjectX500Principal) + } else // UNAVAILABLE (нет CRL издателя в кэше / CRL выключен) нефатален: // CA без опубликованного CRL — легитимный случай (легаси-CA), это // сохраняет историческое поведение, когда такой случай молча @@ -221,6 +320,52 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { return true } + /** + * Допускает ли назначение ключа формирование подписи: `digitalSignature` + * либо `nonRepudiation` (RFC 5280 §4.2.1.3, биты 0 и 1). Расширения нет — + * ограничений нет. + * + * Номер политики сертификата (`certificatePolicies`) здесь НЕ проверяется: + * условия его применения задаёт политика конкретного УЦ, и они не выводимы + * из самого сертификата. Значения политик публикуются в + * [kz.ncanode.dto.certificate.CertificateInfo.policies], чтобы проверяющая + * сторона могла применить свои условия (п. 16 приказа №500/НҚ). + */ + fun permitsSignature(): Boolean { + val keyUsage = x509Certificate.keyUsage ?: return true + if (keyUsage.size < 2) return true + return keyUsage[0] || keyUsage[1] + } + + /** + * Срок действия каждого сертификата в цепочке, начиная с [start], на + * момент [date]. + * + * Обход идёт по уже проставленным ссылкам `issuerCertificate` (их + * выставляет `CaService` для всего бандла), поэтому дополнительных поисков + * не делает. Останавливается на самоподписанном сертификате либо там, где + * издателя в бандле нет: всё, что попало в `NCANODE_CA_URL`, — это + * настроенный оператором якорь доверия. Защита от петли на + * кросс-подписанных сертификатах — по паре (subject, серийник). + */ + private fun isChainDateValid(start: CertificateWrapper, date: Date): Boolean { + val seen = mutableSetOf>() + var current: CertificateWrapper? = start + while (current != null) { + if (!seen.add(current.subjectX500Principal to current.x509Certificate.serialNumber)) return true + if (!current.isDateValid(date)) { + log.warn( + "Certificate {} in the certification chain is not valid at {}", + current.subjectX500Principal, date, + ) + return false + } + if (current.subjectX500Principal == current.issuerX500Principal) return true + current = current.issuerCertificate + } + return true + } + fun isDateValid(): Boolean = isDateValid(Date()) fun isDateValid(date: Date): Boolean = @@ -305,6 +450,9 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { var country: String? = null var locality: String? = null var state: String? = null + var uid: String? = null + var businessCategory: String? = null + var domainComponent: String? = null for (rdn in ldapName.rdns) { val type = rdn.type @@ -324,7 +472,28 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { } type.equals("O", ignoreCase = true) -> organization = value type.equals("OU", ignoreCase = true) -> bin = value.removePrefix("BIN") - type.equals("G", ignoreCase = true) -> lastName = value + // `X500Principal.toString()` печатает отчество как GIVENNAME — + // одного "G" не хватало, и поле молча оставалось пустым на + // всех сертификатах НУЦ. + type.equals("G", ignoreCase = true) + || type.equals("GN", ignoreCase = true) + || type.equals("GIVENNAME", ignoreCase = true) -> lastName = value + // Оба поля обязательны в шаблоне «Казначейство – Клиент» + // (приказ №522/НҚ): код клиента и роль участника. + // businessCategory в RFC2253-имени от JDK выглядит как + // `OID.2.5.4.15`, keyword'а у него нет. + type.equals("businessCategory", ignoreCase = true) + || type.equals("OID.2.5.4.15", ignoreCase = true) + || type == "2.5.4.15" -> businessCategory = value + type.equals("DC", ignoreCase = true) -> domainComponent = value + // UID (0.9.2342.19200300.100.1.1) — в шаблоне «цифровая + // система юридического лица» (приказ №522/НҚ, приложение 3, + // структура 10) здесь лежит OID самой цифровой системы, + // выданный уполномоченным органом. По п. 17 тех же Правил + // ключ ставится именно в систему с этим OID, так что для + // серверного подписанта это опорное поле — раньше оно было + // видно только внутри сырого dn. + type.equals("UID", ignoreCase = true) -> uid = value } } @@ -342,6 +511,9 @@ class CertificateWrapper(val x509Certificate: X509Certificate) { country = country, locality = locality, state = state, + uid = uid, + businessCategory = businessCategory, + domainComponent = domainComponent, dn = dn, ) } catch (e: InvalidNameException) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa3926dc..0d9438ba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,8 +31,17 @@ ncanode: url: ${NCANODE_PROXY_URL:} username: ${NCANODE_PROXY_USERNAME:} password: ${NCANODE_PROXY_PASSWORD:} + sign: + # Проверять сертификат подписанта перед подписанием (п. 4 Правил + # формирования и проверки подлинности ЭЦП, приказ МИИ РК №500/НҚ): + # срок действия, цепочка, отзыв (OCSP → CRL) и назначение ключа. + # Включение меняет поведение всех sign-эндпойнтов и добавляет + # OCSP-запрос на каждое подписание, поэтому по умолчанию выключено. + certificateCheck: ${NCANODE_SIGN_CERT_CHECK:false} ocsp: - url: ${NCANODE_OCSP_URL:http://ocsp.pki.gov.kz/} + # Два адреса — как в профилях сертификатов НУЦ (приказ МИИ РК №522/НҚ): + # ocsp1 — зеркало. Опрашиваются по очереди до первого авторитетного ответа. + url: ${NCANODE_OCSP_URL:http://ocsp.pki.gov.kz/ http://ocsp1.pki.gov.kz/} parallelEnabled: ${NCANODE_OCSP_PARALLEL:true} strict: ${NCANODE_OCSP_STRICT:false} ca: @@ -46,7 +55,10 @@ ncanode: retries: ${NCANODE_CRL_RETRIES:3} maxSizeMb: ${NCANODE_CRL_MAX_SIZE_MB:64} onDemandMaxEntries: ${NCANODE_CRL_ONDEMAND_MAX:64} - ttl: ${NCANODE_CA_CRL_TTL:1440} + # КУЦ РК обновляет свой СОС не реже раза в 24 часа (приказ №522/НҚ, + # приложение 1, п. 15). TTL в те же 24 часа означал бы худший случай + # «весь цикл на устаревшем списке», поэтому опрашиваем вдвое чаще. + ttl: ${NCANODE_CA_CRL_TTL:720} url: ${NCANODE_CA_CRL_URL:http://crl.root.gov.kz/gost.crl http://crl.root.gov.kz/rsa.crl http://crl.root.gov.kz/gost2015_2022.crl http://crl.root.gov.kz/rsa2020.crl} delta: enabled: false diff --git a/src/test/kotlin/kz/ncanode/ades/CadesInspectorTest.kt b/src/test/kotlin/kz/ncanode/ades/CadesInspectorTest.kt index 40ed3fdb..44bedfc3 100644 --- a/src/test/kotlin/kz/ncanode/ades/CadesInspectorTest.kt +++ b/src/test/kotlin/kz/ncanode/ades/CadesInspectorTest.kt @@ -152,4 +152,39 @@ class CadesInspectorTest : FunSpec({ CadesInspector.inspect(cms.encoded, provider)[0].signingCertificateMatches shouldBe false } + + test("signingCertificateMatches rejects a different certificate") { + // Атрибут лежит под подписью и намертво привязывает её к одному + // сертификату (RFC 5035). Тот же контейнер, сверенный с чужим + // сертификатом, привязку не подтверждает — на этом же вызове стоит + // /cms/verify, где атрибут обязателен для CAdES (п. 8 приказа №500/НҚ). + val signed = sign() + val signer = signed.signerInfos.signers.first() as SignerInformation + val otherCertificate = keyStore("legal_ceo_valid.p12").certificate.x509Certificate + + CadesInspector.signingCertificateMatches(signer, signerCertificate, provider) shouldBe true + CadesInspector.signingCertificateMatches(signer, otherCertificate, provider) shouldBe false + } + + test("signingCertificateMatches passes a signature without the attribute") { + // Обычный PKCS#7 без signingCertificateV2 проверять не на что — + // отсутствие атрибута не должно означать отказ. + val generator = CMSSignedDataGenerator() + generator.addSigner( + signerKeyStore.privateKey, + signerCertificate, + getDigestAlgorithmOidBYSignAlgorithmOid(signerCertificate.sigAlgOID), + ) + generator.addCertificatesAndCRLs( + CertStore.getInstance( + "Collection", CollectionCertStoreParameters(listOf(signerCertificate)), KalkanProvider.PROVIDER_NAME, + ) + ) + val plain = generator.generate( + CMSProcessableByteArray("plain".toByteArray()), true, KalkanProvider.PROVIDER_NAME, + ) + val signer = plain.signerInfos.signers.first() as SignerInformation + + CadesInspector.signingCertificateMatches(signer, signerCertificate, provider) shouldBe true + } }) diff --git a/src/test/kotlin/kz/ncanode/service/CrlServiceCacheLimitsTest.kt b/src/test/kotlin/kz/ncanode/service/CrlServiceCacheLimitsTest.kt index 6a904ff3..7b8a7907 100644 --- a/src/test/kotlin/kz/ncanode/service/CrlServiceCacheLimitsTest.kt +++ b/src/test/kotlin/kz/ncanode/service/CrlServiceCacheLimitsTest.kt @@ -211,8 +211,11 @@ class CrlServiceCacheLimitsTest : FunSpec({ ).certificate // Сертификат выпущен тем же CA, что и used.crl — CRL пригождается, - // мусорные файлы отбрасываются как нечитаемые. - crlService.verify(certificate).result shouldBe CrlResult.ACTIVE + // мусорные файлы отбрасываются как нечитаемые. Вердикт EXPIRED, а не + // ACTIVE: fixture давно вне своего периода действия (п. 18 приказа + // №500/НҚ). Для этого теста важно другое — что список пригодился и + // потому отмечен как использованный. + crlService.verify(certificate).result shouldBe CrlResult.EXPIRED crlService.enforceOnDemandLimit() @@ -222,6 +225,29 @@ class CrlServiceCacheLimitsTest : FunSpec({ idleTwo.isFile shouldBe true } + test("counts both on-demand caches against a single limit") { + // Списки, скачанные по freshestCRL, лежат в отдельном каталоге — иначе + // delta без deltaCRLIndicator выглядела бы обычным списком при отборе + // base. Но кэш это один, и потолок у него общий: иначе разностные + // копились бы мимо ограничения. + val cacheDir = tempCacheDir("two-dirs") + val now = System.currentTimeMillis() + val oldestFull = putOnDemand(cacheDir, "full-old.crl", "filler".toByteArray(), now - 3 * 60_000) + val newerFull = putOnDemand(cacheDir, "full-new.crl", "filler".toByteArray(), now - 60_000) + val deltaDirFile = File(cacheDir, "crl/$serviceType/ondemand-delta").apply { mkdirs() } + val delta = File(deltaDirFile, "delta.crl").apply { + writeBytes("filler".toByteArray()) + setLastModified(now) + } + + service(cacheDir, onDemandLimit = 2).enforceOnDemandLimit() + + // Три файла на два места: уходит самый давний, независимо от каталога. + oldestFull.exists() shouldBe false + newerFull.isFile shouldBe true + delta.isFile shouldBe true + } + test("leaves the on-demand cache alone when the limit is not positive") { val cacheDir = tempCacheDir("unlimited") val now = System.currentTimeMillis() diff --git a/src/test/kotlin/kz/ncanode/service/CrlServiceTest.kt b/src/test/kotlin/kz/ncanode/service/CrlServiceTest.kt index 2ecff1df..b26e7261 100644 --- a/src/test/kotlin/kz/ncanode/service/CrlServiceTest.kt +++ b/src/test/kotlin/kz/ncanode/service/CrlServiceTest.kt @@ -18,7 +18,17 @@ import kz.ncanode.configuration.crl.CrlConfiguration import kz.ncanode.crl.CrlIndex import kz.ncanode.crl.RevokedEntry import kz.ncanode.dto.crl.CrlResult +import kz.ncanode.wrapper.CertificateWrapper import kz.ncanode.wrapper.KalkanWrapper +import org.bouncycastle.asn1.x509.CRLDistPoint +import org.bouncycastle.asn1.x509.DistributionPoint +import org.bouncycastle.asn1.x509.DistributionPointName +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import java.io.File import java.math.BigInteger import java.nio.file.Files @@ -114,8 +124,8 @@ class CrlServiceTest : FunSpec({ every { service.getCrlFiles(any()) } answers { val arg = firstArg() when { - arg.contains("delta") -> listOf(deltaFile) - arg.contains("full") -> listOf(fullFile) + arg.endsWith("/delta") -> listOf(deltaFile) + arg.endsWith("/full") -> listOf(fullFile) else -> emptyList() } } @@ -172,7 +182,12 @@ class CrlServiceTest : FunSpec({ null, TestResources.P12_PASSWORD, ) val status = buildService().verify(ks.certificate) - status.result shouldBe CrlResult.ACTIVE + // Fixture'ы в репозитории давно вышли из своего периода действия + // (nextUpdate — октябрь 2025), поэтому положительным вердикт быть не + // может: серийника в списке нет, но и свидетельствовать о «сейчас» он + // не вправе. ACTIVE на живых списках проверяется интеграционными + // тестами, а на mock'ах — кейсами delta ниже. + status.result shouldBe CrlResult.EXPIRED } test("verify() ignores CRL issued by a different CA") { @@ -286,28 +301,31 @@ class CrlServiceTest : FunSpec({ status.fresh shouldBe true } - test("verify() marks ACTIVE as stale when CRL nextUpdate has passed") { - // Протухший CRL остаётся детектором отзыва в AND-режиме, но его - // ACTIVE не может единолично реабилитировать cert при упавшем OCSP. + test("verify() reports EXPIRED when CRL nextUpdate has passed") { + // п. 18 приказа №500/НҚ: истёкший период действия CRL — отрицательный + // результат его проверки. Протухший список остаётся детектором отзыва, + // но отсутствие серийника в нём ничего не доказывает: всё, что издатель + // опубликовал после nextUpdate, туда физически не попало. val ks = kalkanWrapper.read( TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, ) val status = serviceWithSingleCrl(ks.certificate, Date(System.currentTimeMillis() - 86_400_000L)) .verify(ks.certificate) - status.result shouldBe CrlResult.ACTIVE + status.result shouldBe CrlResult.EXPIRED status.fresh shouldBe false } - test("verify() marks ACTIVE as stale when CRL has no nextUpdate (nonconforming)") { + test("verify() reports EXPIRED when CRL has no nextUpdate (nonconforming)") { // RFC 5280 §5.1.2.5 требует nextUpdate; его отсутствие трактуем - // консервативно — CRL непригоден как fallback-основание. + // консервативно — период действия не подтверждён, значит по п. 18 + // приказа №500/НҚ результат проверки такого списка отрицательный. val ks = kalkanWrapper.read( TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, ) val status = serviceWithSingleCrl(ks.certificate, null).verify(ks.certificate) - status.result shouldBe CrlResult.ACTIVE + status.result shouldBe CrlResult.EXPIRED status.fresh shouldBe false } @@ -371,8 +389,8 @@ class CrlServiceTest : FunSpec({ every { service.getCrlFiles(any()) } answers { val arg = firstArg() when { - arg.contains("delta") -> if (deltaCrl != null) listOf(deltaFile) else emptyList() - arg.contains("full") -> if (baseCrl != null) listOf(baseFile) else emptyList() + arg.endsWith("/delta") -> if (deltaCrl != null) listOf(deltaFile) else emptyList() + arg.endsWith("/full") -> if (baseCrl != null) listOf(baseFile) else emptyList() else -> emptyList() } } @@ -553,7 +571,9 @@ class CrlServiceTest : FunSpec({ TestResources.loadAsBase64("p12/legal_ceo_valid.p12"), null, TestResources.P12_PASSWORD, ) - buildService().verify(ks.certificate).result shouldBe CrlResult.ACTIVE + // Тот же fixture, тот же истёкший период — вердикт EXPIRED, а не + // падение: важно, что чужой CRL просто не участвует. + buildService().verify(ks.certificate).result shouldBe CrlResult.EXPIRED } // ---- повторы при плановой загрузке ---- @@ -682,4 +702,227 @@ class CrlServiceTest : FunSpec({ Thread.interrupted() } } + + // ---- профили приказа МИИ РК №522/НҚ (в силе с 11.09.2026) ---- + + test("verify() keeps a delta whose freshestCRL extension is critical") { + // Приложение 3, структуры 13 и 14: разностный СОС НУЦ маркируется + // расширением `freshestCRL` (2.5.29.46) с пометкой critical. Раньше + // такой список отбрасывался целиком («критичное расширение, которого + // мы не понимаем»), то есть delta не участвовала бы в проверке и + // досрочные отзывы остались бы невидимы. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, + ) + val cert = ks.certificate + val issuer = cert.x509Certificate.issuerX500Principal + val base = mockBaseCrl(issuer, crlNum = 1346, nextUpd = future, revoked = null) + val delta = mockDeltaCrl( + issuer, crlNum = 57725, baseCrlNum = 1346, nextUpd = future, + revoked = crlEntry(CRLReason.KEY_COMPROMISE), + ).apply { every { criticalExtensionOids } returns setOf("2.5.29.27", "2.5.29.46") } + + val status = serviceWith(base, delta).verify(cert) + status.result shouldBe CrlResult.REVOKED + status.file shouldBe "delta_x.crl" + } + + test("verify() keeps a base CRL whose freshestCRL extension is critical") { + // То же расширение может оказаться critical и на полном списке — + // указатель на delta не сужает охват СОС, поэтому дисквалифицировать + // из-за него нечего. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, + ) + val cert = ks.certificate + val issuer = cert.x509Certificate.issuerX500Principal + val base = mockBaseCrl( + issuer, crlNum = 1346, nextUpd = future, revoked = crlEntry(CRLReason.KEY_COMPROMISE), + ).apply { every { criticalExtensionOids } returns setOf("2.5.29.46") } + + serviceWith(base, null).verify(cert).result shouldBe CrlResult.REVOKED + } + + test("verify() never promotes a delta-endpoint list without deltaCRLIndicator to base") { + // Если НУЦ выпустит разностный СОС строго по букве профиля — с + // `freshestCRL` вместо `deltaCRLIndicator`, — разбор не опознает его + // как delta. Отбор base идёт по максимальному CRLNumber, а у delta он + // на порядки больше (на бою 57 725 против 1 346), так что она выиграла + // бы отбор, и вердикт считался бы по ней одной: всё, что отозвано + // только в полном списке, вернулось бы как ACTIVE. Провенанс файла + // (скачан с delta-эндпоинта) это предотвращает. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, + ) + val cert = ks.certificate + val issuer = cert.x509Certificate.issuerX500Principal + val base = mockBaseCrl( + issuer, crlNum = 1346, nextUpd = future, revoked = crlEntry(CRLReason.KEY_COMPROMISE), + ) + // Delta без deltaCRLIndicator: baseCrlNumber отсутствует, зато + // CRLNumber огромный, и про наш серийник она ничего не знает. + val unmarkedDelta = mockCrlIndex(issuer).apply { + every { criticalExtensionOids } returns setOf("2.5.29.46") + every { nextUpdate } returns future + every { crlNumber } returns BigInteger.valueOf(57725) + every { find(any()) } returns null + } + + val status = serviceWith(base, unmarkedDelta).verify(cert) + status.result shouldBe CrlResult.REVOKED + status.file shouldBe "base.crl" + } + + // ---- on-demand загрузка по расширениям сертификата ---- + + /** + * Сертификат с заданными точками распространения. Ключ и подпись здесь не + * важны — проверяется только разбор расширений, поэтому RSA и JDK-провайдер. + */ + fun certWithDistributionPoints(crlUris: List>, freshestUris: List>): CertificateWrapper { + fun distPoints(groups: List>) = CRLDistPoint( + groups.map { uris -> + DistributionPoint( + DistributionPointName( + GeneralNames(uris.map { GeneralName(GeneralName.uniformResourceIdentifier, it) }.toTypedArray()) + ), + null, null, + ) + }.toTypedArray() + ) + + val keyPair = java.security.KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val name = org.bouncycastle.asn1.x500.X500Name("CN=dp-test") + val now = System.currentTimeMillis() + val builder = JcaX509v3CertificateBuilder( + name, BigInteger.ONE, Date(now - 86_400_000L), Date(now + 86_400_000L), name, keyPair.public, + ) + if (crlUris.isNotEmpty()) { + builder.addExtension(Extension.cRLDistributionPoints, false, distPoints(crlUris)) + } + if (freshestUris.isNotEmpty()) { + builder.addExtension(Extension.freshestCRL, false, distPoints(freshestUris)) + } + val holder = builder.build(JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private)) + return CertificateWrapper(JcaX509CertificateConverter().getCertificate(holder)) + } + + /** + * Сервис, у которого подменена только сама загрузка: остальной путь + * on-demand (фильтры, дедуп, TTL) работает по-настоящему. + */ + fun serviceRecordingDownloads(downloaded: MutableList): CrlService { + val cacheDir = Files.createTempDirectory("ncanode-ondemand-test").toFile().apply { deleteOnExit() } + val crlConfig = mockk(relaxed = true).apply { + every { isEnabled } returns true + every { isCacheEnabled } returns false + every { isStrict } returns false + every { ttl } returns 60 + every { urlList } returns emptyMap() + every { delta } returns null + } + val directoryService = mockk(relaxed = true).apply { + every { getCachePathFor(any()) } returns cacheDir + } + val service = spyk( + CrlService( + directoryService, crlConfig, mockk(relaxed = true), + HttpClientConfiguration(), mockk(relaxed = true), "test", + ) + ) + every { service.getCrlFiles(any()) } returns emptyList() + every { service.downloadCrlOrThrow(any(), any()) } answers { + downloaded.add(firstArg() + " <- " + secondArg().toString()) + } + return service + } + + test("on-demand fetch takes one mirror per distribution point") { + // Профили из приказа №522/НҚ объявляют пару crl.pki.gov.kz + + // crl1.pki.gov.kz внутри ОДНОЙ точки распространения. По RFC 5280 + // §4.2.1.13 это адреса одного и того же списка, поэтому качаем по + // первому сработавшему — иначе в кэше оказались бы два экземпляра + // одного 20-МБ СОС, и оба пришлось бы индексировать. + val downloaded = mutableListOf() + val cert = certWithDistributionPoints( + crlUris = listOf(listOf("http://192.0.2.1/full.crl", "http://192.0.2.2/full.crl")), + freshestUris = emptyList(), + ) + serviceRecordingDownloads(downloaded).verify(cert) + + downloaded shouldBe listOf("crl/test/ondemand <- http://192.0.2.1/full.crl") + } + + test("on-demand fetch follows the freshestCRL extension for the delta list") { + // Адрес разностного СОС НУЦ публикует в freshestCRL (2.5.29.46), а не + // в cRLDistributionPoints — это видно и на сертификатах тестового + // контура. Пока расширение не читалось, для издателя вне конфигурации + // мы работали на одном полном списке, то есть не видели досрочных + // отзывов, ради которых delta и публикуется. + val downloaded = mutableListOf() + val cert = certWithDistributionPoints( + crlUris = listOf(listOf("http://192.0.2.1/full.crl")), + freshestUris = listOf(listOf("http://192.0.2.3/delta.crl")), + ) + serviceRecordingDownloads(downloaded).verify(cert) + + // Разностный список уходит в СВОЙ каталог: каталог — единственный + // носитель провенанса, на котором держится защита отбора base. + downloaded shouldBe listOf( + "crl/test/ondemand <- http://192.0.2.1/full.crl", + "crl/test/ondemand-delta <- http://192.0.2.3/delta.crl", + ) + } + + test("verify() never promotes an on-demand freshestCRL list without deltaCRLIndicator to base") { + // Тот же риск, что и для конфигурационного delta-каталога, но через + // freshestCRL: список, скачанный по адресу из сертификата, приходит в + // ondemand-delta. Без провенанса он попал бы в кандидаты на base и + // выиграл бы по CRLNumber — всё, отозванное только в полном списке, + // вернулось бы как ACTIVE. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), null, TestResources.P12_PASSWORD, + ) + val cert = ks.certificate + val issuer = cert.x509Certificate.issuerX500Principal + val base = mockBaseCrl( + issuer, crlNum = 1346, nextUpd = future, revoked = crlEntry(CRLReason.KEY_COMPROMISE), + ) + val unmarkedDelta = mockCrlIndex(issuer).apply { + every { criticalExtensionOids } returns setOf("2.5.29.46") + every { nextUpdate } returns future + every { crlNumber } returns BigInteger.valueOf(57725) + every { find(any()) } returns null + } + + val baseFile = mockCrlFile("base") + val onDemandDeltaFile = mockCrlFile("ondemand_delta") + val crlConfig = mockk(relaxed = true).apply { + every { isEnabled } returns true + every { isCacheEnabled } returns false + every { ttl } returns null + every { urlList } returns emptyMap() + every { delta } returns null + } + val service = spyk( + CrlService( + mockk(relaxed = true), crlConfig, mockk(relaxed = true), + HttpClientConfiguration(), mockk(relaxed = true), "test", + ) + ) + every { service.getCrlFiles(any()) } answers { + val arg = firstArg() + when { + arg.endsWith("/ondemand-delta") -> listOf(onDemandDeltaFile) + arg.endsWith("/full") -> listOf(baseFile) + else -> emptyList() + } + } + every { service.loadIndex(baseFile) } returns base + every { service.loadIndex(onDemandDeltaFile) } returns unmarkedDelta + + val status = service.verify(cert) + status.result shouldBe CrlResult.REVOKED + status.file shouldBe "base.crl" + } }) diff --git a/src/test/kotlin/kz/ncanode/service/OcspServiceTest.kt b/src/test/kotlin/kz/ncanode/service/OcspServiceTest.kt index a2f8ce6b..1a7c8ec3 100644 --- a/src/test/kotlin/kz/ncanode/service/OcspServiceTest.kt +++ b/src/test/kotlin/kz/ncanode/service/OcspServiceTest.kt @@ -6,12 +6,18 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kz.gov.pki.kalkan.jce.provider.KalkanProvider import kz.ncanode.TestResources import kz.ncanode.configuration.HttpClientConfiguration import kz.ncanode.configuration.OcspConfiguration import kz.ncanode.dto.ocsp.OcspResult import kz.ncanode.wrapper.KalkanWrapper +import org.bouncycastle.asn1.ASN1Enumerated +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERTaggedObject import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream @@ -159,4 +165,134 @@ class OcspServiceTest : FunSpec({ statuses[0].result shouldBe OcspResult.UNAVAILABLE statuses[0].message!! shouldContain "limit" } + + /** + * Ответ-ошибка OCSP (RFC 6960 §4.2.1): `OCSPResponse ::= SEQUENCE { + * responseStatus ENUMERATED, responseBytes [0] OPTIONAL }`. У ошибок + * responseBytes отсутствует, поэтому DER — пять байт, и подписи там нет + * по определению. + */ + fun ocspErrorResponse(status: Int): ByteArray = + byteArrayOf(0x30, 0x03, 0x0A, 0x01, status.toByte()) + + /** + * Формально успешный ответ, внутри которого не BasicOCSPResponse, а мусор. + * Разбор упадёт с OCSPException — это авторитетно плохой ответ (UNKNOWN), + * в отличие от ответа-ошибки. + */ + fun ocspSuccessfulWithGarbage(): ByteArray = DERSequence( + arrayOf( + ASN1Enumerated(0), + DERTaggedObject( + true, 0, + DERSequence( + arrayOf( + ASN1ObjectIdentifier("1.3.6.1.5.5.7.48.1.1"), + DEROctetString(byteArrayOf(1, 2, 3)), + ) + ), + ), + ) + ).encoded + + test("verify() treats an OCSP error response (tryLater) as UNAVAILABLE, not UNKNOWN") { + // Приказ МИИ РК №522/НҚ обязывает НУЦ держать два респондера. Ответ + // "tryLater" от перегруженного зеркала — это "ответа сейчас нет", а не + // приговор сертификату: он не подписан и о сертификате ничего не + // сообщает. Раньше он давал UNKNOWN, а UNKNOWN в isValid фатален — + // валидная подпись объявлялась недействительной, хотя первый + // респондер мог ответить ACTIVE. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), + null, TestResources.P12_PASSWORD, + ) + val issuer = ks.certificate + val client = mockk().apply { + every { + send(any(), any>()) + } returns httpResponse(ocspErrorResponse(3)) + } + val service = OcspService(kalkanProvider, ocspConfig, client, httpConfig) + + val statuses = service.verify(ks.certificate, issuer) + statuses shouldHaveSize 1 + statuses[0].result shouldBe OcspResult.UNAVAILABLE + statuses[0].message!! shouldContain "tryLater" + } + + test("verify() falls through to the next responder while answers stay UNAVAILABLE") { + // Два адреса (в профилях НУЦ это ocsp + ocsp1) — зеркала одного + // сервиса. Пока ответа нет, обходим их все: недоступность первого не + // должна лишать нас второго. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), + null, TestResources.P12_PASSWORD, + ) + val issuer = ks.certificate + val twoResponders = OcspConfiguration().apply { + url = "http://ocsp.example.kz/ http://ocsp1.example.kz/" + isStrict = true // игнорируем AIA сертификата, берём оба адреса из конфига + } + val client = mockk().apply { + // answers, а не returns: тело мока — один поток, и второй + // респондер получил бы уже вычитанный. + every { + send(any(), any>()) + } answers { httpResponse(ocspErrorResponse(3)) } + } + val service = OcspService(kalkanProvider, twoResponders, client, httpConfig) + + val statuses = service.verify(ks.certificate, issuer) + statuses shouldHaveSize 2 + statuses.map { it.result }.toSet() shouldBe setOf(OcspResult.UNAVAILABLE) + verify(exactly = 2) { client.send(any(), any>()) } + } + + test("verify() stops at the first authoritative answer and leaves the mirror alone") { + // Ответ пришёл и он авторитетный (пусть и плохой — разбор не удался, + // это UNKNOWN, fail-closed). Опрашивать зеркало незачем: второй запрос + // только удвоил бы нагрузку на респондера НУЦ. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), + null, TestResources.P12_PASSWORD, + ) + val issuer = ks.certificate + val twoResponders = OcspConfiguration().apply { + url = "http://ocsp.example.kz/ http://ocsp1.example.kz/" + isStrict = true + } + val client = mockk().apply { + every { + send(any(), any>()) + } returns httpResponse(ocspSuccessfulWithGarbage()) + } + val service = OcspService(kalkanProvider, twoResponders, client, httpConfig) + + val statuses = service.verify(ks.certificate, issuer) + statuses shouldHaveSize 1 + statuses[0].result shouldBe OcspResult.UNKNOWN + verify(exactly = 1) { client.send(any(), any>()) } + } + + test("verify() returns UNAVAILABLE when the responder body is empty") { + // HTTP 200 с пустым телом (прокси-заглушка, оборванный ответ): + // разбор такого «ответа» падал NPE мимо всех наших catch — то есть + // весь verify отдавал 500 вместо вердикта по сертификату. + val ks = kalkanWrapper.read( + TestResources.loadAsBase64("p12/individual_valid.p12"), + null, TestResources.P12_PASSWORD, + ) + val issuer = ks.certificate + val client = mockk().apply { + every { + send(any(), any>()) + } answers { httpResponse(ByteArray(0)) } + } + val service = OcspService(kalkanProvider, ocspConfig, client, httpConfig) + + val statuses = service.verify(ks.certificate, issuer) + statuses shouldHaveSize 1 + statuses[0].result shouldBe OcspResult.UNAVAILABLE + statuses[0].message!! shouldContain "empty body" + } }) diff --git a/src/test/kotlin/kz/ncanode/service/SignerCertificateCheckTest.kt b/src/test/kotlin/kz/ncanode/service/SignerCertificateCheckTest.kt new file mode 100644 index 00000000..15bd0da0 --- /dev/null +++ b/src/test/kotlin/kz/ncanode/service/SignerCertificateCheckTest.kt @@ -0,0 +1,119 @@ +package kz.ncanode.service + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kz.ncanode.configuration.OcspConfiguration +import kz.ncanode.configuration.SignConfiguration +import kz.ncanode.dto.crl.CrlResult +import kz.ncanode.dto.crl.CrlStatus +import kz.ncanode.dto.ocsp.OcspResult +import kz.ncanode.dto.ocsp.OcspStatus +import kz.ncanode.exception.ClientException +import kz.ncanode.wrapper.CertificateWrapper +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.util.Date + +/** + * Проверка сертификата подписанта перед подписанием — п. 4 Правил формирования + * и проверки подлинности ЭЦП (приказ МИИ РК №500/НҚ). + * + * Режим выключен по умолчанию, поэтому здесь важны обе стороны: что выключенная + * проверка не делает вообще ничего (в том числе не ходит в сеть на каждое + * подписание) и что включённая отказывает клиенту на непригодном ключе. + */ +class SignerCertificateCheckTest : FunSpec({ + + fun certificate(cn: String, notBefore: Date, notAfter: Date): CertificateWrapper { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val name = X500Name("CN=$cn") + val holder = JcaX509v3CertificateBuilder(name, BigInteger.ONE, notBefore, notAfter, name, keyPair.public) + .build(JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private)) + return CertificateWrapper(JcaX509CertificateConverter().getCertificate(holder)) + } + + val hourAgo = Date(System.currentTimeMillis() - 3_600_000L) + val yearAhead = Date(System.currentTimeMillis() + 365L * 86_400_000L) + val yearAgo = Date(System.currentTimeMillis() - 365L * 86_400_000L) + + class Fixture(val service: CertificateService, val ocspService: OcspService, val crlService: CrlService) + + fun fixture( + enabled: Boolean, + ocsp: List = listOf(OcspStatus(result = OcspResult.ACTIVE)), + crl: CrlStatus = CrlStatus(result = CrlResult.ACTIVE, fresh = true), + ): Fixture { + val ocspService = mockk().apply { every { verify(any(), any()) } returns ocsp } + val crlService = mockk().apply { every { verify(any()) } returns crl } + // Издателем самоподписанного сертификата выступает он сам — обход + // цепочки на нём и остановится. + val caService = mockk().apply { every { getRootCertificateFor(any()) } answers { firstArg() } } + val service = CertificateService( + crlService = crlService, + ocspService = ocspService, + caService = caService, + kalkanWrapper = mockk(relaxed = true), + ocspConfiguration = OcspConfiguration(), + signConfiguration = SignConfiguration().apply { isCertificateCheck = enabled }, + ) + return Fixture(service, ocspService, crlService) + } + + test("disabled check touches neither the certificate nor the network") { + // Дефолт. Просроченный ключ подписывает как раньше — поведение + // существующих развёртываний не меняется, и лишнего OCSP-запроса на + // каждое подписание не появляется. + val expired = certificate("expired", yearAgo, hourAgo) + val fx = fixture(enabled = false) + + shouldNotThrowAny { fx.service.ensureSignerCertificateUsable(expired) } + verify(exactly = 0) { fx.ocspService.verify(any(), any()) } + verify(exactly = 0) { fx.crlService.verify(any()) } + } + + test("enabled check refuses an expired signer certificate") { + // п. 4, подпункт 2: срок действия должен наступить и не истечь. + val expired = certificate("expired", yearAgo, hourAgo) + + val error = shouldThrow { + fixture(enabled = true).service.ensureSignerCertificateUsable(expired) + } + error.message!! shouldContain "cannot be used for signing" + } + + test("enabled check refuses a revoked signer certificate") { + // п. 4, подпункт 3: отзыв проверяется через OCSP. Причина отзыва — + // компрометация, она не прощается ни на какой момент. + val revoked = certificate("revoked", hourAgo, yearAhead) + val fx = fixture( + enabled = true, + ocsp = listOf( + OcspStatus( + result = OcspResult.REVOKED, + revocationTime = Date(System.currentTimeMillis() - 60_000L), + // RFC 5280 §5.3.1: keyCompromise = 1. + revocationReason = 1, + ), + ), + ) + + shouldThrow { fx.service.ensureSignerCertificateUsable(revoked) } + } + + test("enabled check passes a usable signer certificate") { + val usable = certificate("usable", hourAgo, yearAhead) + val fx = fixture(enabled = true) + + shouldNotThrowAny { fx.service.ensureSignerCertificateUsable(usable) } + verify(exactly = 1) { fx.ocspService.verify(any(), any()) } + } +}) diff --git a/src/test/kotlin/kz/ncanode/wrapper/CertificateWrapperTest.kt b/src/test/kotlin/kz/ncanode/wrapper/CertificateWrapperTest.kt index bfe03998..ac321eef 100644 --- a/src/test/kotlin/kz/ncanode/wrapper/CertificateWrapperTest.kt +++ b/src/test/kotlin/kz/ncanode/wrapper/CertificateWrapperTest.kt @@ -14,7 +14,15 @@ import kz.ncanode.dto.crl.CrlResult import kz.ncanode.dto.crl.CrlStatus import kz.ncanode.dto.ocsp.OcspResult import kz.ncanode.dto.ocsp.OcspStatus +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import java.io.File +import java.math.BigInteger +import java.security.KeyPairGenerator import java.util.Date class CertificateWrapperTest : FunSpec({ @@ -303,4 +311,210 @@ class CertificateWrapperTest : FunSpec({ cert.crlStatus = CrlStatus(result = CrlResult.UNAVAILABLE) cert.isValid(date, checkOcsp = true, checkCrl = true) shouldBe false } + + // ---- шаблоны и расширения приказа МИИ РК №522/НҚ ---- + + test("keyUser EKU includes ORGANIZATION_DIGITAL_SYSTEM for цифровая-система cert") { + // Шаблон «цифровая система юридического лица» (1.2.398.3.3.4.1.2.6). + // OID появился в приказе №522/НҚ, но НУЦ выдаёт такие сертификаты уже + // сейчас — он есть на нашем же тестовом ключе. Нераспознанный OID + // молча выпадал из keyUser (mapNotNull), и /x509/info занижал шаблон. + val cert = certFromP12("legal_infosystem_valid.p12") + val info = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false) + info.keyUser.shouldNotBeNull() shouldContain CertificateKeyUser.ORGANIZATION_DIGITAL_SYSTEM + } + + test("keyUser EKU includes TREASURY_CLIENT for Казначейство-Клиент cert") { + val cert = certFromP12("legal_treasury_valid.p12") + val info = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false) + info.keyUser.shouldNotBeNull() shouldContain CertificateKeyUser.TREASURY_CLIENT + } + + test("crlDistributionPoints groups mirrors of one distribution point together") { + // Внутри одной точки распространения адреса — зеркала одного списка + // (RFC 5280 §4.2.1.13). Профили НУЦ объявляют пару crl + crl1, и + // группировка — то, что удерживает нас от загрузки двух копий. + val cert = certFromP12("individual_valid.p12") + cert.crlDistributionPoints shouldHaveAtLeastSize 1 + cert.crlDistributionPoints.flatten() shouldBe cert.crlList + } + + test("freshestCrlDistributionPoints extracts the delta CRL address") { + // Адрес разностного СОС лежит в freshestCRL (2.5.29.46), а не в + // cRLDistributionPoints — так и на боевых, и на тестовых сертификатах НУЦ. + val cert = certFromP12("individual_valid.p12") + val delta = cert.freshestCrlDistributionPoints.flatten() + delta shouldHaveAtLeastSize 1 + delta.none { it in cert.crlList } shouldBe true + } + + test("Subject UID carries the digital system OID") { + // Шаблон «цифровая система юридического лица»: UID + // (0.9.2342.19200300.100.1.1) несёт OID самой системы, в которую по + // п. 17 Правил выдачи разрешено ставить закрытый ключ. Раньше поле + // было видно только внутри сырого dn. + val cert = certFromP12("legal_infosystem_valid.p12") + val subject = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false).subject + + subject.shouldNotBeNull().uid shouldBe "1.2.398.6.10.1.1" + subject.shouldNotBeNull().bin shouldBe "123456789021" + } + + test("Treasury client cert exposes its client code and role") { + // В шаблоне «участник цифровой системы "Казначейство – Клиент"» + // businessCategory (код клиента) и DC (роль) — обязательные поля. + // businessCategory в имени от JDK приходит как OID.2.5.4.15: keyword'а + // у него нет, и без явного разбора значение терялось. + val cert = certFromP12("legal_treasury_valid.p12") + val subject = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false).subject + .shouldNotBeNull() + + subject.businessCategory shouldBe "KS1234" + subject.domainComponent shouldBe "ROLE02" + } + + test("Subject GIVENNAME is parsed (отчество)") { + // `X500Principal.toString()` печатает отчество как GIVENNAME, а разбор + // ждал только "G" — поле молча оставалось пустым на всех сертификатах + // НУЦ, хотя в профиле оно есть. + val cert = certFromP12("individual_valid.p12") + val subject = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false).subject + .shouldNotBeNull() + + subject.lastName shouldBe "ТЕСТОВИЧ" + subject.surName shouldBe "ТЕСТОВ" + } + + // ---- Правила формирования и проверки подлинности ЭЦП (приказ №500/НҚ) ---- + + /** + * Самоподписанный RSA-сертификат с заданным периодом и расширениями. + * Криптография здесь не проверяется — `isValid` работает с уже + * построенными связями `issuerCertificate`, — поэтому важен только состав + * полей. + */ + fun syntheticCert( + cn: String, + notBefore: Date, + notAfter: Date, + keyUsage: KeyUsage? = null, + issuerCn: String = cn, + ): CertificateWrapper { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val name = X500Name("CN=$cn") + val issuerName = X500Name("CN=$issuerCn") + val builder = + JcaX509v3CertificateBuilder(issuerName, BigInteger.ONE, notBefore, notAfter, name, keyPair.public) + if (keyUsage != null) builder.addExtension(Extension.keyUsage, true, keyUsage) + val holder = builder.build(JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private)) + return CertificateWrapper(JcaX509CertificateConverter().getCertificate(holder)) + } + + val hourAgo = Date(System.currentTimeMillis() - 3_600_000L) + val yearAhead = Date(System.currentTimeMillis() + 365L * 86_400_000L) + val yearAgo = Date(System.currentTimeMillis() - 365L * 86_400_000L) + + test("isValid rejects the leaf when a certificate higher up the chain has expired") { + // п. 16 Правил: срок действия проверяется у всей цепочки до доверенного + // корня, и истечение любого звена — отрицательный результат. Раньше + // смотрели только на непосредственного издателя, так что протухший + // корень оставался незамеченным. + val root = syntheticCert("expired root", yearAgo, hourAgo) + val intermediate = syntheticCert("intermediate", hourAgo, yearAhead, issuerCn = "expired root") + val leaf = syntheticCert("leaf", hourAgo, yearAhead, issuerCn = "intermediate") + intermediate.issuerCertificate = root + leaf.issuerCertificate = intermediate + + leaf.isValid(Date(), checkOcsp = false, checkCrl = false) shouldBe false + } + + test("isValid accepts the leaf when the whole chain is within its validity period") { + val root = syntheticCert("root", hourAgo, yearAhead) + val intermediate = syntheticCert("intermediate", hourAgo, yearAhead, issuerCn = "root") + val leaf = syntheticCert("leaf", hourAgo, yearAhead, issuerCn = "intermediate") + // Самоподписанный корень CaService ссылает сам на себя — обход обязан + // на этом остановиться, а не зациклиться. + root.issuerCertificate = root + intermediate.issuerCertificate = root + leaf.issuerCertificate = intermediate + + leaf.isValid(Date(), checkOcsp = false, checkCrl = false) shouldBe true + } + + test("CA certificate stays valid for /x509/info and is refused on the signature path") { + // keyUsage у сертификатов НУЦ — keyCertSign + cRLSign, без + // digitalSignature. Требование п. 16 Правил относится к сертификату + // ПОДПИСЫВАЮЩЕГО ЛИЦА, поэтому info-эндпойнты (они лишь описывают + // сертификат) обязаны по-прежнему считать такой сертификат + // действительным, а путь проверки подписи — отвергать. + val ca = CertificateWrapper.fromBytes(TestResources.loadBytes("ca/nca_gost2022_test.cer")) + ca.shouldNotBeNull() + ca.issuerCertificate = CertificateWrapper.fromBytes( + TestResources.loadBytes("ca/root_test_gost_2022.cer") + ) + val insideValidity = Date( + (ca.x509Certificate.notBefore.time + ca.x509Certificate.notAfter.time) / 2 + ) + + ca.permitsSignature() shouldBe false + ca.isValid(insideValidity, checkOcsp = false, checkCrl = false) shouldBe true + ca.isValid( + insideValidity, checkOcsp = false, checkCrl = false, requireSigningKeyUsage = true, + ) shouldBe false + } + + test("isValid rejects a certificate whose keyUsage forbids signing") { + // п. 16 Правил: назначение ключа должно допускать подпись. Ключ только + // для шифрования подписывать не может, каким бы валидным ни был сам + // сертификат. + val cipherOnly = syntheticCert("cipher only", hourAgo, yearAhead, KeyUsage(KeyUsage.keyEncipherment)) + cipherOnly.issuerCertificate = cipherOnly + cipherOnly.permitsSignature() shouldBe false + cipherOnly.isValid( + Date(), checkOcsp = false, checkCrl = false, requireSigningKeyUsage = true, + ) shouldBe false + } + + test("isValid allows a certificate without the keyUsage extension") { + // RFC 5280 §4.2.1.3: расширение опционально, его отсутствие не + // ограничивает назначение ключа. + val noKeyUsage = syntheticCert("no key usage", hourAgo, yearAhead) + noKeyUsage.issuerCertificate = noKeyUsage + noKeyUsage.permitsSignature() shouldBe true + noKeyUsage.isValid( + Date(), checkOcsp = false, checkCrl = false, requireSigningKeyUsage = true, + ) shouldBe true + } + + test("NCA certificate publishes its policy OID") { + // п. 16 Правил требует сверять номер политики сертификата с условиями + // её применения. Сами условия задаёт УЦ, поэтому мы публикуем номер — + // у сертификатов НУЦ это 1.2.398.3.3.2. + val cert = certFromP12("legal_ceo_valid.p12") + cert.certificatePolicies shouldContain "1.2.398.3.3.2" + val info = cert.toCertificateInfo(Date(), checkOcsp = false, checkCrl = false) + info.policies.shouldNotBeNull() shouldContain "1.2.398.3.3.2" + } + + test("isValid rejects the certificate when the only CRL is past its validity period") { + // п. 18 Правил: истёкший CRL — отрицательный результат проверки отзыва. + // Опереться больше не на что: OCSP не запрашивали. + val cert = syntheticCert("leaf", hourAgo, yearAhead) + cert.issuerCertificate = cert + cert.crlStatus = CrlStatus(result = CrlResult.EXPIRED) + + cert.isValid(Date(), checkOcsp = false, checkCrl = true) shouldBe false + } + + test("isValid tolerates an expired CRL when OCSP answered positively") { + // п. 16 Правил допускает проверку отзыва «посредством сервиса OCSP либо + // CRL»: авторитетный ACTIVE от респондера — самостоятельный + // положительный результат, и протухший список его не отменяет. + val cert = syntheticCert("leaf", hourAgo, yearAhead) + cert.issuerCertificate = cert + cert.crlStatus = CrlStatus(result = CrlResult.EXPIRED) + cert.ocspStatus = listOf(OcspStatus(result = OcspResult.ACTIVE)) + + cert.isValid(Date(), checkOcsp = true, checkCrl = true) shouldBe true + } })