feat(cryptocli): Go-клиент через PKCS#11 — КриптоПро CSP, Рутокен, etc.
Заменили stub-клиент на полноценный PKCS#11 wrapper через
github.com/miekg/pkcs11. Поддерживает любой PKCS#11-совместимый
провайдер: КриптоПро CSP (libcppkcs11.so), Рутокен ЭЦП 2.0
(librtpkcs11ecp.so), Валидата, ViPNet и др.
internal/cryptocli/client.go:
- cryptocli.Client с конфигом {Provider, ModulePath, PIN, SlotID}
- Health() — Initialize → GetInfo → GetSlotList(WithToken=true) →
GetTokenInfo для каждого слота. Возвращает HealthInfo с
Cryptoki/library версиями, manufacturer и списком подключённых
токенов (label, model, serial)
- DefaultModulePath() — путь до .so для каждого провайдера (CSP,
Рутокен, Валидата, ViPNet)
- Если провайдер=stub или модуль не найден — клиент возвращает
понятную ошибку, lk-gateway переходит в режим без криптографии
В admin/setup wizard:
- В карточке «Криптография» появилась кнопка «Проверить подключение СКЗИ»
→ POST /admin/setup/crypto/check → cryptocli.Health() → flash с
результатом сверху страницы (список токенов или диагностика)
- Поле "UDS-сокет" помечено как legacy (для старого Java crypto-service),
основное поле — «Путь к модулю PKCS#11» с дефолтами и подсказками
- Расширен список провайдеров: добавлен «rutoken»
internal/cryptocli/client_test.go:
- Тесты Stub, MissingModule, EmptyPath, DefaultModulePath
- Старые тесты на UDS-сокет удалены (теперь PKCS#11)
Зависимость: github.com/miekg/pkcs11 v1.1.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/miekg/pkcs11 v1.1.2
|
||||
golang.org/x/text v0.22.0
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
|
||||
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
+195
-39
@@ -1,60 +1,216 @@
|
||||
// Package cryptocli — Go-клиент crypto-service по UDS. На M1 — заглушка,
|
||||
// возвращает ErrNotImplemented. Реальная реализация — после
|
||||
// генерации gRPC-стабов из proto (когда прокси откроет proto-tooling
|
||||
// и Go-зависимости).
|
||||
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
|
||||
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
|
||||
// открывает сессию, перечисляет токены, читает сертификаты и
|
||||
// предоставляет операции Sign/Verify.
|
||||
//
|
||||
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
|
||||
// возвращает понятную ошибку и помечает себя как «провайдер
|
||||
// недоступен». В этом случае lk-gateway переходит в режим stub:
|
||||
// XMLDSig-подписи проходят без реальной проверки (только для
|
||||
// дев-стендов и демо).
|
||||
package cryptocli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/pkcs11"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// ErrNotImplemented возвращается клиентом до подключения реального gRPC.
|
||||
var ErrNotImplemented = errors.New("cryptocli: не реализовано (ждём gRPC-стабы из proto)")
|
||||
// Provider — тип СКЗИ-провайдера.
|
||||
type Provider string
|
||||
|
||||
// Client — клиент crypto-service по Unix Domain Socket.
|
||||
type Client struct {
|
||||
socketPath string
|
||||
dialer net.Dialer
|
||||
timeout time.Duration
|
||||
}
|
||||
// Известные провайдеры.
|
||||
const (
|
||||
ProviderStub Provider = "stub"
|
||||
ProviderCryptoPro Provider = "cryptopro"
|
||||
ProviderRutoken Provider = "rutoken"
|
||||
ProviderValidata Provider = "validata"
|
||||
ProviderVipNet Provider = "vipnet"
|
||||
)
|
||||
|
||||
// Option настраивает Client.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithTimeout задаёт таймаут на одну операцию.
|
||||
func WithTimeout(t time.Duration) Option {
|
||||
return func(c *Client) { c.timeout = t }
|
||||
}
|
||||
|
||||
// NewClient создаёт клиента к UDS. На M1 — без реального gRPC.
|
||||
// Используется как заглушка, реализующая m2mcore.CryptoVerifier.
|
||||
func NewClient(socketPath string, opts ...Option) *Client {
|
||||
c := &Client{socketPath: socketPath, timeout: 5 * time.Second}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
// 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 c
|
||||
return ""
|
||||
}
|
||||
|
||||
// VerifyXMLDSig вызывает CryptoService.VerifyXMLDSig. На M1 — stub.
|
||||
func (c *Client) VerifyXMLDSig(ctx context.Context, _ []byte) (m2mcore.CertInfo, error) {
|
||||
// На M1 проверяем только доступность сокета и возвращаем
|
||||
// ErrNotImplemented. Это позволяет m2m-core логировать «crypto-service
|
||||
// доступен, но криптография ещё не подключена» отдельно от «сокета
|
||||
// нет совсем».
|
||||
dctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
conn, err := c.dialer.DialContext(dctx, "unix", c.socketPath)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
_ = conn.Close()
|
||||
return m2mcore.CertInfo{}, ErrNotImplemented
|
||||
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.
|
||||
|
||||
@@ -2,51 +2,61 @@ package cryptocli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
func TestClientReturnsErrNotImplementedWhenSocketReachable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "crypto.sock")
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
func TestStubProviderHealthOK(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
||||
h, err := cli.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("listen unix: %v", err)
|
||||
t.Fatalf("Health: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
cli := cryptocli.NewClient(socketPath, cryptocli.WithTimeout(time.Second))
|
||||
_, err = cli.VerifyXMLDSig(context.Background(), []byte("<xml/>"))
|
||||
if !errors.Is(err, cryptocli.ErrNotImplemented) {
|
||||
t.Errorf("ожидалась ErrNotImplemented, получено %v", err)
|
||||
if h.Provider != string(cryptocli.ProviderStub) {
|
||||
t.Errorf("Provider = %q", h.Provider)
|
||||
}
|
||||
if !strings.Contains(h.Message, "stub") {
|
||||
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsDialErrorWhenSocketMissing(t *testing.T) {
|
||||
cli := cryptocli.NewClient("/nonexistent/crypto.sock", cryptocli.WithTimeout(200*time.Millisecond))
|
||||
_, err := cli.VerifyXMLDSig(context.Background(), []byte("x"))
|
||||
func TestModulePathMissing(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.ProviderCryptoPro,
|
||||
ModulePath: "/nonexistent/libcppkcs11.so",
|
||||
})
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка диалинга на несуществующий сокет")
|
||||
t.Fatal("ожидалась ошибка о ненайденном модуле")
|
||||
}
|
||||
if errors.Is(err, cryptocli.ErrNotImplemented) {
|
||||
t.Errorf("при отсутствующем сокете не должно быть ErrNotImplemented")
|
||||
if !strings.Contains(err.Error(), "не найден") {
|
||||
t.Errorf("неинформативная ошибка: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyModulePath(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о пустом ModulePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModulePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
p cryptocli.Provider
|
||||
want string
|
||||
}{
|
||||
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
|
||||
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
|
||||
{cryptocli.ProviderStub, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := cryptocli.DefaultModulePath(c.p)
|
||||
if got != c.want {
|
||||
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// setupHandlers — обработчики /admin/setup/*.
|
||||
@@ -37,11 +39,43 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
})
|
||||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
||||
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
|
||||
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||||
}
|
||||
|
||||
// checkCrypto — POST /admin/setup/crypto/check. Запускает Health()
|
||||
// текущего провайдера PKCS#11 без изменения настроек.
|
||||
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot()
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||
ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so
|
||||
})
|
||||
defer cli.Close()
|
||||
info, err := cli.Health(r.Context())
|
||||
if err != nil {
|
||||
setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
|
||||
if info.CryptokiVersion != "" {
|
||||
msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
|
||||
}
|
||||
if len(info.Tokens) > 0 {
|
||||
msg += ". Токены:"
|
||||
for _, t := range info.Tokens {
|
||||
msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
|
||||
}
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
|
||||
// SetupData — данные для шаблона admin_setup.html.
|
||||
type SetupData struct {
|
||||
page
|
||||
|
||||
Reference in New Issue
Block a user