From 0ef75e05e891f703b0567d24af4496bb3da23315 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 14 May 2026 15:34:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20UI=20+=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D1=81=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0=D1=85=20+=20URL=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=BE=D0=B2=20=D0=9D=D0=A0?= =?UTF-8?q?=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После реальной установки КриптоПро 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) --- internal/cryptocli/client.go | 142 ++++++++++++++++++ internal/lkgateway/setup.go | 118 +++++++++++++-- .../web/templates/admin_help_systems.html | 63 ++++++-- .../lkgateway/web/templates/admin_setup.html | 88 +++++++++-- 4 files changed, 373 insertions(+), 38 deletions(-) diff --git a/internal/cryptocli/client.go b/internal/cryptocli/client.go index 9a40c93..f344a26 100644 --- a/internal/cryptocli/client.go +++ b/internal/cryptocli/client.go @@ -13,6 +13,8 @@ package cryptocli import ( "context" "crypto/sha256" + "crypto/x509" + "encoding/asn1" "encoding/hex" "errors" "fmt" @@ -140,6 +142,146 @@ func (c *Client) Health(_ context.Context) (HealthInfo, error) { 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 с подписанной полезной diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 7ccb0de..6d04442 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -19,6 +19,23 @@ import ( "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" ) +// crypto-сертификаты на текущих токенах (для отображения на странице). +func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate { + s := h.rc.Snapshot() + if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" { + return nil + } + cli := cryptocli.New(cryptocli.Config{ + Provider: cryptocli.Provider(s.Crypto.Provider), + ModulePath: s.Crypto.JCPPath, + }) + defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + certs, _ := cli.FindCertificates(ctx) + return certs +} + // setupHandlers — обработчики /admin/setup/*. type setupHandlers struct { rc *RuntimeConfig @@ -46,6 +63,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto) mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense) mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro) + mux.HandleFunc("/admin/setup/crypto/import-cert", h.importCertificate) mux.HandleFunc("/admin/setup/nsd", h.saveNSD) mux.HandleFunc("/admin/setup/lk", h.saveLK) mux.HandleFunc("/admin/setup/test-run", h.testRun) @@ -137,6 +155,80 @@ func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output)) } +// importCertificate — POST /admin/setup/crypto/import-cert (multipart). +// Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или +// .cer/.crt (только публичный сертификат). Импортирует через certmgr +// КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для +// корневых). +func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(64 << 20); err != nil { + setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error()) + return + } + file, header, err := r.FormFile("cert") + if err != nil { + setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt") + return + } + defer file.Close() + pin := strings.TrimSpace(r.FormValue("pin")) + store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых + + dir := "/tmp/bj-certs" + _ = os.MkdirAll(dir, 0o755) + safeName := filepath.Base(header.Filename) + dst := filepath.Join(dir, safeName) + out, err := os.Create(dst) + if err != nil { + setupFlash(w, r, "Импорт сертификата: не получилось создать "+dst+": "+err.Error()) + return + } + if _, err := io.Copy(out, file); err != nil { + out.Close() + setupFlash(w, r, "Импорт сертификата: ошибка записи: "+err.Error()) + return + } + out.Close() + + certmgr := "/opt/cprocsp/bin/amd64/certmgr" + if _, err := os.Stat(certmgr); err != nil { + setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.") + return + } + if store == "" { + store = "uMy" + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + lower := strings.ToLower(safeName) + var cmdOut string + switch { + case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"): + // PKCS#12 — импорт через certmgr -inst с PIN + args := []string{"-inst", "-pfx", "-file", dst, "-store", store} + if pin != "" { + args = append(args, "-pin", pin) + } + cmdOut, err = runCmd(ctx, certmgr, args...) + case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"): + // Голый сертификат — импорт в хранилище без приватного ключа + cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store) + default: + setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt") + return + } + if err != nil { + setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut)) + return + } + setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut)) +} + // runCmdInDir выполняет команду в указанной рабочей директории. func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) @@ -219,12 +311,13 @@ func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) { // SetupData — данные для шаблона admin_setup.html. type SetupData struct { page - Settings Settings - Readiness []Readiness - ReadyCount int - TotalCount int - Flash string - Error string + Settings Settings + Readiness []Readiness + ReadyCount int + TotalCount int + Certificates []cryptocli.Certificate + Flash string + Error string } func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) { @@ -237,12 +330,13 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flas } } data := SetupData{ - page: nowPage("Настройка", "setup"), - Settings: s, - Readiness: r, - ReadyCount: ready, - TotalCount: len(r), - Flash: flash, + page: nowPage("Настройка", "setup"), + Settings: s, + Readiness: r, + ReadyCount: ready, + TotalCount: len(r), + Certificates: h.listCertsForUI(), + Flash: flash, } if errVal := errMsgFromQuery(_q(w)); errVal != "" { data.Error = errVal diff --git a/internal/lkgateway/web/templates/admin_help_systems.html b/internal/lkgateway/web/templates/admin_help_systems.html index 58fd4ff..678d46f 100644 --- a/internal/lkgateway/web/templates/admin_help_systems.html +++ b/internal/lkgateway/web/templates/admin_help_systems.html @@ -7,27 +7,64 @@
-

1. Интеграционный шлюз НРД (ИШ)

-

Основной канал отправки M2M-сообщений в НРД. ИШ сам подписывает пакеты ЭДО, нам криптография в этом канале не нужна.

-

Профили (см. Настройка → Интеграционный шлюз НРД):

+

1. Интеграционный шлюз НРД (ИШ) и контуры тестирования

+

Основной канал отправки M2M-сообщений в НРД — Web-сервис ONYX через ИШ. ИШ сам подписывает пакеты ЭДО, поэтому в этом канале нам криптография не требуется. Дистрибутив ИШ скачивается с сайта НРД: www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4

+

Адреса контуров (из DOC/Ссылки для доступа в тестовые контуры.pdf):

- + - - - - - - + + + + + + + + + + + + + + + + + + + + + + +
ПрофильСредаКриптография
СервисGUEST · ГОСТGUEST · RSATEST3 · ГОСТTEST3 · RSA
guest-gostГостевой контур (без проверок)ГОСТ Р 34.10-2012
guest-rsaГостевой контурRSA
test3-gostТестовый контур TEST3ГОСТ
test3-rsaTEST3RSA
prod-gostПродуктивныйГОСТ
prod-rsaПродуктивныйRSA
WEB-сервис ONYX (нужен нам)gost-gt.nsd.rursa-gt.nsd.rugost-t3.nsd.rursa-t3.nsd.ru
Единый кабинет администратора НРДиректcabinet-gt.nsd.ru/wr-admin/cabinet-t3.nsd.ru/wr-admin/
WEB-сервис Agate (WSAlameda)gost-gt.nsd.ru/WSAlamedags/ · rsa-gt.nsd.ru/WSAlamedags/gost-t3.nsd.ru/WSAlameda/ · rsa-t3.nsd.ru/WSAlameda/
Депозитарно-клиринговых услуг (новый)отсутствуетcabinet-t3.nsd.ru/dcs_new/
+

Полный URL WSL для ONYX: https://<host>/onyx-ms/OnyxEdoWSService/OnyxEdo. Для прод-контура ссылки опубликованы в Анкете НРД ЭДО (anketa_nrd_edo_2022_07_11.pdf на сайте НРД). IP gost.nsd.ru — 91.208.232.151 (для настройки межсетевого экрана).

Что указать в Настройка → ИШ:

    -
  • Профиль (например, test3-gost)
  • -
  • URL ИШ — обычно http://localhost:8080 если ИШ установлен на той же ВМ
  • -
  • Ключевой контейнер — имя на стороне ИШ, например TEST3_GOST_CONTAINER
  • +
  • Профиль (например, test3-gost) — при выборе URL и контейнер заполняются автоматически
  • +
  • URL ONYX — например https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo
  • +
  • Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)

Без настроенного ИШ система работает в mock-режиме: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.

+
+ +
+

1а. Сертификаты УЦ НРД (для проверки квитанций)

+

НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище mroot (доверенные корневые).

+
    +
  1. Скачать сертификаты с сайта УЦ НРД: www.nsd.ru/workflow/system/cryptography/ (или из дистрибутива ИШ).
  2. +
  3. В /admin/setup → раздел «Импорт сертификата» → выбрать файл .cer, тип хранилища mroot — корневой УЦ, нажать «Импортировать». Под капотом выполняется certmgr -inst -file root.cer -store mroot.
  4. +
  5. Промежуточные сертификаты УЦ — в хранилище uRoot.
  6. +
  7. Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в uMy (как корреспондента), либо оставить в mroot, если он самоподписной.
  8. +
+

Наши сертификаты для отправки в НРД (получаются из другого УЦ — нашей организации):

+
    +
  1. Сертификат подписи нашей организации (с приватным ключом в виде .pfx/.p12 или на Рутокен) — импортировать в uMy с PIN.
  2. +
  3. Цепочка сертификатов вашего УЦ — в mroot (корневой) и uRoot (промежуточные).
  4. +
  5. После импорта проверить: certmgr -list -store uMy и cpverify.
  6. +
+

Полный цикл обмена сертификатами с НРД описан в DOC/Инструкция M2M.pdf и DOC/Презентация MOEX MOST.pdf.

+

Документация по подключению: DOC/instr_podkl_stend_v3.pdf, DOC/Ссылки для доступа в тестовые контуры.pdf.

diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index f27ac07..01c9839 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -89,6 +89,42 @@ +
+

Сертификаты на токенах

+ {{if .Certificates}} + + + + {{range .Certificates}} + + + + + + + + + {{end}} + +
КомуКем выданИННДействителенТокенПриватный ключ
{{.SubjectCN}}{{.IssuerCN}}{{.INN}}до {{.NotAfter.Format "02.01.2006"}}«{{.TokenLabel}}» (slot {{.SlotID}}){{if .HasPrivateKey}}есть{{else}}нет{{end}}
+ {{else}} +

На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.

+ {{end}} + +
+

Импорт сертификата (.pfx / .cer / .crt)

+

PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — /admin/help/cryptopro.

+
+ + + + +
+

Активация лицензии

@@ -101,34 +137,60 @@
-

Интеграционный шлюз НРД

+

Интеграционный шлюз НРД (ИШ)

{{if not .Settings.NSD.IGWBaseURL}}Сейчас mock-режим — Decision эмитируется через 3 секунды после Send.{{else}}Профиль {{.Settings.NSD.Profile}}, ИШ {{.Settings.NSD.IGWBaseURL}}.{{end}}

+

Подключение к стендам: /admin/help/systems — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4.

Изменить параметры ИШ
- + + + + + + +
- - + +
- +
-

При сохранении выполняется GET {URL}/healthz. Пустой URL = вернуться к mock-режиму.

+

При смене профиля URL ONYX автозаполнится по таблице НРД (из DOC/Ссылки для доступа в тестовые контуры.pdf). При сохранении проверяется доступность URL.

+