// 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)