feat(admin): импорт сертификатов через UI + список сертификатов на токенах + URL контуров НРД

После реальной установки КриптоПро CSP добавлены следующие
функциональности:

cryptocli/client.go:
- FindCertificates() — перечисляет CKO_CERTIFICATE объекты на всех
  подключенных слотах через PKCS#11, парсит X.509, извлекает CN, ИНН
  (OID 1.2.643.3.131.1.1), серийник, срок действия. Для каждого
  сертификата проверяет наличие парного приватного ключа (CKO_PRIVATE_KEY
  с тем же CKA_ID).
- Тип Certificate с полями: SubjectCN, IssuerCN, INN, Serial, NotBefore,
  NotAfter, DER, HasPrivateKey, TokenLabel, SlotID.

internal/lkgateway/setup.go:
- handler importCertificate (POST /admin/setup/crypto/import-cert,
  multipart). Принимает .pfx/.p12 (с PIN) или .cer/.crt. Запускает
  certmgr -inst -pfx или -inst с выбором хранилища (uMy/mroot/uRoot).
- listCertsForUI() — вспомогательный метод renderSetup для подгрузки
  актуального списка сертификатов с подключенных токенов при каждом
  открытии страницы.

internal/lkgateway/web/templates/admin_setup.html:
- секция «Сертификаты на токенах» с таблицей (Кому/Кем выдан/ИНН/срок/
  токен/есть-ли-приватный-ключ).
- форма «Импорт сертификата (.pfx/.cer/.crt)» с полями для PIN и
  выбора хранилища.
- блок «Интеграционный шлюз НРД»: добавлен JS автозаполнения URL ONYX
  и контейнера по выбору профиля (guest/test3/prod × gost/rsa) —
  значения из DOC/Ссылки для доступа в тестовые контуры.pdf.

internal/lkgateway/web/templates/admin_help_systems.html:
- секция «Интеграционный шлюз НРД и контуры тестирования» дополнена
  полной таблицей URL-ов сервисов GUEST/TEST3 (ONYX, Agate, DCS,
  Единый кабинет, Корпоративные действия). IP gost.nsd.ru для
  настройки межсетевого экрана.
- новая секция «Сертификаты УЦ НРД (для проверки квитанций)» с
  пошаговой инструкцией: куда импортировать корневой сертификат УЦ
  НРД, куда промежуточные, куда наши сертификаты из стороннего УЦ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-05-14 15:34:32 +03:00
parent 3e34995e69
commit 0ef75e05e8
4 changed files with 373 additions and 38 deletions
+142
View File
@@ -13,6 +13,8 @@ package cryptocli
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"errors"
"fmt"
@@ -140,6 +142,146 @@ func (c *Client) Health(_ context.Context) (HealthInfo, error) {
return h, nil
}
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
type Certificate struct {
SlotID uint
TokenLabel string
Label string // CKA_LABEL (объект на токене)
SubjectCN string
IssuerCN string
Serial string
NotBefore time.Time
NotAfter time.Time
INN string // если есть в OID 1.2.643.3.131.1.1
DER []byte
HasPrivateKey bool // найден ли парный приватный ключ на токене
}
// FindCertificates перечисляет сертификаты на всех подключенных
// токенах. Не требует Login для публичных сертификатов; для контейнеров
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return nil, err
}
slots, err := c.ctx.GetSlotList(true)
if err != nil {
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
}
var out []Certificate
for _, slot := range slots {
tokInfo, _ := c.ctx.GetTokenInfo(slot)
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
if err != nil {
// продолжаем — возможно один слот занят, другие доступны
continue
}
out = append(out, certs...)
}
return out, nil
}
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
// читает DER и парсит x509.
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
if err != nil {
return nil, fmt.Errorf("OpenSession: %w", err)
}
defer func() { _ = c.ctx.CloseSession(sess) }()
template := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
}
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
return nil, fmt.Errorf("FindObjectsInit: %w", err)
}
handles, _, err := c.ctx.FindObjects(sess, 32)
_ = c.ctx.FindObjectsFinal(sess)
if err != nil {
return nil, fmt.Errorf("FindObjects: %w", err)
}
out := make([]Certificate, 0, len(handles))
for _, h := range handles {
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
})
if err != nil {
continue
}
cert := Certificate{
SlotID: slot,
TokenLabel: tokenLabel,
}
var idAttr []byte
for _, a := range attrs {
switch a.Type {
case pkcs11.CKA_VALUE:
cert.DER = a.Value
case pkcs11.CKA_LABEL:
cert.Label = string(a.Value)
case pkcs11.CKA_ID:
idAttr = a.Value
}
}
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
parsed, err := x509.ParseCertificate(cert.DER)
if err == nil {
cert.SubjectCN = parsed.Subject.CommonName
cert.IssuerCN = parsed.Issuer.CommonName
cert.Serial = parsed.SerialNumber.Text(16)
cert.NotBefore = parsed.NotBefore
cert.NotAfter = parsed.NotAfter
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
cert.INN = extractINN(parsed)
}
// Проверим есть ли парный приватный ключ.
if len(idAttr) > 0 {
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
}
out = append(out, cert)
}
return out, nil
}
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
tmpl := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
}
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
return false
}
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
handles, _, err := c.ctx.FindObjects(sess, 1)
return err == nil && len(handles) > 0
}
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
func extractINN(c *x509.Certificate) string {
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
for _, name := range c.Subject.Names {
if name.Type.Equal(innOID) {
if s, ok := name.Value.(string); ok {
return s
}
}
}
return ""
}
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
// Реальная проверка XMLDSig потребует канонизации XML и parsing
// сертификатов; пока возвращает CertInfo с подписанной полезной