feat: живой цикл M2M с НРД + мастер установки ключа на флешку

Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-06-19 00:03:21 +03:00
parent 6e503433d4
commit 9737c787f9
110 changed files with 10771 additions and 1690 deletions
+219 -273
View File
@@ -1,351 +1,297 @@
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
// открывает сессию, перечисляет токены, читает сертификаты и
// предоставляет операции Sign/Verify.
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
// Клиент L».
//
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
// возвращает понятную ошибку и помечает себя как «провайдер
// недоступен». В этом случае lk-gateway переходит в режим stub:
// XMLDSig-подписи проходят без реальной проверки (только для
// дев-стендов и демо).
// На дев-стендах без поднятого сайдкара (стандартный путь
// /run/bj/crypto.sock не существует) клиент возвращает понятную
// ошибку «провайдер недоступен» и 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"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// Provider — тип СКЗИ-провайдера.
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
type Provider string
// Известные провайдеры.
const (
ProviderStub Provider = "stub"
ProviderCryptoPro Provider = "cryptopro"
ProviderRutoken Provider = "rutoken"
ProviderValidata Provider = "validata"
ProviderVipNet Provider = "vipnet"
ProviderStub Provider = "stub"
ProviderValidata Provider = "validata"
)
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
// для указанного провайдера. Используется в /admin/setup как placeholder.
// DefaultModulePath сохранена для обратной совместимости с UI;
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
// crypto-service. Возвращаемое значение информативное.
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"
if p == ProviderValidata {
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
}
return ""
}
// Config — конфигурация клиента.
type Config struct {
Provider Provider
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
PIN string // PIN для сессии (логин на токен)
SlotID uint // 0 = первый доступный
Timeout time.Duration
// SocketPath — путь к UDS-сокету crypto-service.
// Пустое значение = /run/bj/crypto.sock.
SocketPath string
// Provider — желаемый провайдер; информативно (см. выше).
Provider Provider
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
ModulePath string
// Timeout — таймаут одной gRPC-операции.
Timeout time.Duration
}
// Client — PKCS#11-клиент к СКЗИ.
// Client — gRPC-клиент к crypto-service.
type Client struct {
cfg Config
mu sync.Mutex
ctx *pkcs11.Ctx
opened bool
cfg Config
mu sync.Mutex
conn *grpc.ClientConn
api cryptopb.CryptoServiceClient
}
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
// делает Connect или явный Ping (Health-check на admin-странице).
// New создаёт клиент. Само соединение поднимается лениво при первом
// вызове.
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
if cfg.SocketPath == "" {
cfg.SocketPath = "/run/bj/crypto.sock"
}
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)
}
// Close закрывает gRPC-соединение.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return HealthInfo{}, err
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
c.api = nil
return 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
return nil
}
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
// ensureConn устанавливает gRPC-канал к UDS-сокету при первом
// использовании. Используем встроенный в grpc-go резолвер unix:.
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.api != nil {
return nil
}
target := "unix:" + c.cfg.SocketPath
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("cryptocli: dial %s: %w", c.cfg.SocketPath, err)
}
c.conn = conn
c.api = cryptopb.NewCryptoServiceClient(conn)
return nil
}
// Health — gRPC Health-вызов. Если сокет недоступен (сайдкар не
// поднят) — вернёт «провайдер недоступен» с явной ошибкой.
func (c *Client) Health(ctx context.Context) (HealthInfo, error) {
if c.cfg.Provider == ProviderStub {
return HealthInfo{
Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена.",
}, nil
}
if err := c.ensureConn(); err != nil {
return HealthInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Health(cctx, &cryptopb.HealthRequest{})
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: Health: %w", err)
}
return HealthInfo{
Provider: resp.GetProvider(),
Message: resp.GetVersion(),
ModulePath: c.cfg.SocketPath,
}, nil
}
// Certificate — упрощённое описание сертификата (для совместимости с
// прежним UI). В gRPC-режиме crypto-service возвращает информацию о
// подписанте через VerifyResponse; полный список сертификатов
// (FindCertificates) пока не реализован — для UI возвращаем пустой
// список.
type Certificate struct {
SlotID uint
TokenLabel string
Label string // CKA_LABEL (объект на токене)
Label string
SubjectCN string
IssuerCN string
Serial string
NotBefore time.Time
NotAfter time.Time
INN string // если есть в OID 1.2.643.3.131.1.1
INN string
DER []byte
HasPrivateKey bool // найден ли парный приватный ключ на токене
HasPrivateKey bool
}
// FindCertificates перечисляет сертификаты на всех подключенных
// токенах. Не требует Login для публичных сертификатов; для контейнеров
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
// FindCertificates пока возвращает пустой список — список ключей
// управляется самой Валидатой через её собственный справочник (zcs),
// а bj-server о конкретных сертификатах узнаёт по результатам
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
// gRPC-методом ListCertificates если потребуется.
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
}
return nil, nil
}
slots, err := c.ctx.GetSlotList(true)
// Shutdown — отправляет команду «выйти с exit-code 2» сайдкару.
// systemd с Restart=on-failure поднимет его обратно. Возвращает
// ошибку если соединение разорвалось (что нормально и означает что
// сайдкар уже завершается).
func (c *Client) Shutdown(ctx context.Context) error {
if c.cfg.Provider == ProviderStub {
return errors.New("provider=stub: некуда отправлять Shutdown")
}
if err := c.ensureConn(); err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
_, err := c.api.Shutdown(cctx, &cryptopb.ShutdownRequest{})
// Закрываем соединение, чтобы не держать ссылку на падающий процесс.
_ = c.Close()
return err
}
// ActivateResult — результат переключения профиля Валидаты.
type ActivateResult struct {
OK bool
Provider string
Profile string
Message string
}
// Activate переключает crypto-service на указанный профиль pki1.conf.
// Пустая строка = minimal mode (без профиля).
func (c *Client) Activate(ctx context.Context, profile string) (ActivateResult, error) {
if c.cfg.Provider == ProviderStub {
return ActivateResult{
OK: false,
Provider: string(ProviderStub),
Message: "Провайдер stub — переключение профиля недоступно.",
}, nil
}
if err := c.ensureConn(); err != nil {
return ActivateResult{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Activate(cctx, &cryptopb.ActivateRequest{Profile: profile})
if err != nil {
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %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),
return ActivateResult{
OK: resp.GetOk(),
Provider: resp.GetProvider(),
Profile: resp.GetProfile(),
Message: resp.GetMessage(),
}, nil
}
// Close завершает работу PKCS#11 модуля.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ctx == nil {
return nil
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
// заполненный из gRPC-ответа.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if c.cfg.Provider == ProviderStub {
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
}, nil
}
_ = c.ctx.Finalize()
c.ctx.Destroy()
c.ctx = nil
c.opened = false
return nil
if err := c.ensureConn(); err != nil {
return m2mcore.CertInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.VerifyXMLDSig(cctx, &cryptopb.VerifyRequest{
Payload: payload,
})
if err != nil {
return m2mcore.CertInfo{}, fmt.Errorf("cryptocli: VerifyXMLDSig: %w", err)
}
if !resp.GetValid() {
var msg string
if errs := resp.GetErrors(); len(errs) > 0 {
msg = errs[0]
} else {
msg = "подпись недействительна"
}
return m2mcore.CertInfo{}, errors.New("cryptocli: " + msg)
}
return m2mcore.CertInfo{
SignerCN: resp.GetSignerCn(),
SignerINN: resp.GetSignerInn(),
Serial: resp.GetSerial(),
NotBefore: time.Unix(resp.GetNotBefore(), 0),
NotAfter: time.Unix(resp.GetNotAfter(), 0),
}, nil
}
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
// Должен вызываться под c.mu.Lock.
func (c *Client) ensureInitLocked() error {
if c.opened {
return nil
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
// или к самостоятельной отправке как .p7s).
//
// keyAlias — alias ключа из ПСП Валидаты (пустой = ключ по умолчанию
// активного профиля). profile — имя профиля в pki1.conf, пустой = тот
// что инициализирован.
func (c *Client) SignXMLDSig(ctx context.Context, payload []byte, keyAlias, profile string) ([]byte, error) {
if c.cfg.Provider == ProviderStub {
return nil, errors.New("provider=stub: подпись недоступна")
}
c.ctx = pkcs11.New(c.cfg.ModulePath)
if c.ctx == nil {
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
if err := c.ensureConn(); err != nil {
return nil, err
}
if err := c.ctx.Initialize(); err != nil {
c.ctx.Destroy()
c.ctx = nil
return fmt.Errorf("cryptocli: Initialize: %w", err)
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
Payload: payload,
KeyAlias: keyAlias,
Profile: profile,
})
if err != nil {
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
}
c.opened = true
return nil
return resp.GetSignedXml(), nil
}
// HealthInfo — что показывает /admin/setup и /admin/status.
// HealthInfo — что показывает /admin/setup → СКЗИ.
type HealthInfo struct {
Provider string
ModulePath string
CryptokiVersion string
ManufacturerID string
LibraryVersion string
ModulePath string // в gRPC-режиме — UDS-сокет
CryptokiVersion string // не используется
ManufacturerID string // не используется
LibraryVersion string // не используется
Tokens []TokenInfo
Message string
}
// TokenInfo — описание подключённого токена/контейнера.
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
type TokenInfo struct {
SlotID uint
Label string