0ef75e05e8
После реальной установки КриптоПро 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>
360 lines
12 KiB
Go
360 lines
12 KiB
Go
// 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)
|