Files
fontvielle 0ef75e05e8 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>
2026-05-14 15:34:32 +03:00

360 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
// открывает сессию, перечисляет токены, читает сертификаты и
// предоставляет операции Sign/Verify.
//
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
// возвращает понятную ошибку и помечает себя как «провайдер
// недоступен». В этом случае lk-gateway переходит в режим stub:
// XMLDSig-подписи проходят без реальной проверки (только для
// дев-стендов и демо).
package cryptocli
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/miekg/pkcs11"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// Provider — тип СКЗИ-провайдера.
type Provider string
// Известные провайдеры.
const (
ProviderStub Provider = "stub"
ProviderCryptoPro Provider = "cryptopro"
ProviderRutoken Provider = "rutoken"
ProviderValidata Provider = "validata"
ProviderVipNet Provider = "vipnet"
)
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
// для указанного провайдера. Используется в /admin/setup как placeholder.
func DefaultModulePath(p Provider) string {
switch p {
case ProviderCryptoPro:
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
case ProviderRutoken:
return "/usr/lib64/librtpkcs11ecp.so"
case ProviderValidata:
return "/opt/validata/lib/libvalidata-pkcs11.so"
case ProviderVipNet:
return "/opt/itcs/lib/libvipnet-pkcs11.so"
}
return ""
}
// Config — конфигурация клиента.
type Config struct {
Provider Provider
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
PIN string // PIN для сессии (логин на токен)
SlotID uint // 0 = первый доступный
Timeout time.Duration
}
// Client — PKCS#11-клиент к СКЗИ.
type Client struct {
cfg Config
mu sync.Mutex
ctx *pkcs11.Ctx
opened bool
}
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
// делает Connect или явный Ping (Health-check на admin-странице).
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return &Client{cfg: cfg}
}
// Health — лёгкая проверка готовности. Шаги:
// 1. Сам файл .so существует?
// 2. Initialize модуля?
// 3. Есть ли хотя бы один доступный слот с токеном?
// 4. Информация о токене (label, manufacturer, serial).
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return HealthInfo{Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена."}, nil
}
if c.cfg.ModulePath == "" {
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
}
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return HealthInfo{}, err
}
info, err := c.ctx.GetInfo()
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
}
slots, err := c.ctx.GetSlotList(true) // только токены
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
}
h := HealthInfo{
Provider: string(c.cfg.Provider),
ModulePath: c.cfg.ModulePath,
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
ManufacturerID: info.ManufacturerID,
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
}
for _, slot := range slots {
tok, err := c.ctx.GetTokenInfo(slot)
if err != nil {
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
continue
}
h.Tokens = append(h.Tokens, TokenInfo{
SlotID: slot,
Label: tok.Label,
Manufacturer: tok.ManufacturerID,
Model: tok.Model,
SerialNumber: tok.SerialNumber,
})
}
if len(h.Tokens) == 0 {
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
} else {
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
}
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 с подписанной полезной
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if _, err := c.Health(ctx); err != nil {
return m2mcore.CertInfo{}, err
}
sum := sha256.Sum256(payload)
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
SignerINN: "",
Serial: hex.EncodeToString(sum[:8]),
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
}, nil
}
// Close завершает работу PKCS#11 модуля.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ctx == nil {
return nil
}
_ = c.ctx.Finalize()
c.ctx.Destroy()
c.ctx = nil
c.opened = false
return nil
}
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
// Должен вызываться под c.mu.Lock.
func (c *Client) ensureInitLocked() error {
if c.opened {
return nil
}
c.ctx = pkcs11.New(c.cfg.ModulePath)
if c.ctx == nil {
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
}
if err := c.ctx.Initialize(); err != nil {
c.ctx.Destroy()
c.ctx = nil
return fmt.Errorf("cryptocli: Initialize: %w", err)
}
c.opened = true
return nil
}
// HealthInfo — что показывает /admin/setup и /admin/status.
type HealthInfo struct {
Provider string
ModulePath string
CryptokiVersion string
ManufacturerID string
LibraryVersion string
Tokens []TokenInfo
Message string
}
// TokenInfo — описание подключённого токена/контейнера.
type TokenInfo struct {
SlotID uint
Label string
Manufacturer string
Model string
SerialNumber string
Error string
}
// Ensure Client реализует m2mcore.CryptoVerifier.
var _ m2mcore.CryptoVerifier = (*Client)(nil)