Files
Bridge-and-Join-s/internal/lkgateway/media.go
T
fontvielle 29e19c78a9 Сертификаты: срок действия — по сроку закрытого ключа (PrivateKeyUsagePeriod)
В ГОСТ-сертификатах ключ подписи действует год–15 месяцев, а сам сертификат — до 15 лет;
bj показывал и проверял срок сертификата (например, 06.10.2036 вместо 2027). Теперь
справочник профиля ИШ и сертификаты с флешки берут срок ключа (2.5.29.16), если он раньше;
срок сертификата показывается рядом. У УЦ — по-прежнему срок сертификата.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-21 23:27:28 +03:00

431 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
)
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
// импортированные ключевые контейнеры. На прод-машине пользователь bj
// должен быть владельцем этой директории (создаётся install.sh).
const (
mediaRoot = "/var/lib/bj/media"
mediaISODir = "/var/lib/bj/media/iso"
containersDir = "/var/lib/bj/containers"
profilesDir = "/var/lib/bj/profiles"
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
)
// Medium — один носитель: USB-флешка или распакованная ISO.
type Medium struct {
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
// для ISO — sha256-prefix от исходного файла).
ID string `json:"id"`
// Kind — "usb" или "iso".
Kind string `json:"kind"`
// Mountpoint — корень, по которому сейчас доступен носитель.
Mountpoint string `json:"mountpoint"`
// Source — для ISO: путь до исходного .iso на сервере.
Source string `json:"source,omitempty"`
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
Profile *ValidataProfile `json:"profile,omitempty"`
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
Containers []KeyContainer `json:"containers"`
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
Certificates []CertFile `json:"certificates"`
}
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
type ValidataProfile struct {
Root string `json:"root"` // mountpoint, где найден профиль
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
}
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
type KeyContainer struct {
Path string `json:"path"`
Name string `json:"name"` // имя последней компоненты пути
Files []string `json:"files"` // имена файлов в контейнере
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
}
// CertFile — публичный или PKCS#12 сертификат.
type CertFile struct {
Path string `json:"path"`
Name string `json:"name"`
Format string `json:"format"` // "cer" | "pem" | "pfx"
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
Serial string `json:"serial"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
INN string `json:"inn,omitempty"`
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
ParseError string `json:"parse_error,omitempty"`
}
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
func scanMountpoint(kind, mountpoint, source string) Medium {
m := Medium{
ID: sha1Path(mountpoint),
Kind: kind,
Mountpoint: mountpoint,
Source: source,
}
containers, certs, profile := walkForArtifacts(mountpoint)
m.Containers = containers
m.Certificates = certs
m.Profile = profile
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
for i := range m.Containers {
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
m.Containers[i].Imported = true
}
}
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
// есть директория с тем же именем (имя берётся от носителя).
if m.Profile != nil {
name := filepath.Base(mountpoint)
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
m.Profile.Imported = true
}
}
return m
}
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
// - отдельные сертификаты (.cer/.pfx/...);
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
var containers []KeyContainer
var certs []CertFile
prof := &ValidataProfile{Root: root}
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, p)
depth := strings.Count(rel, string(filepath.Separator))
if depth > 4 {
return filepath.SkipDir
}
if info.IsDir() {
if p != root {
if c, ok := classifyContainer(p); ok {
containers = append(containers, c)
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
// .vdk-файлы для определения профиля Валидаты.
}
}
return nil
}
lower := strings.ToLower(info.Name())
switch {
case strings.HasSuffix(lower, ".pse"):
prof.PSEFiles = append(prof.PSEFiles, rel)
case strings.HasSuffix(lower, ".gdbm"):
prof.GDBMFiles = append(prof.GDBMFiles, rel)
case strings.HasSuffix(lower, ".vdk"):
prof.KeyFiles = append(prof.KeyFiles, rel)
default:
if cert := classifyCertFile(p); cert != nil {
certs = append(certs, *cert)
}
}
return nil
})
// Профилем считаем носитель если есть и pse, и vdk (gdbm
// опционален — но обычно тоже присутствует).
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
prof = nil
}
return containers, certs, prof
}
// classifyContainer — директория является ключевым контейнером, если:
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
// - в ней >=1 файл *.vdk (Валидата Linux).
func classifyContainer(dir string) (KeyContainer, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return KeyContainer{}, false
}
var keyFiles, vdkFiles, allFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
allFiles = append(allFiles, name)
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".vdk"):
vdkFiles = append(vdkFiles, name)
case strings.HasSuffix(lower, ".key"):
keyFiles = append(keyFiles, name)
}
}
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
return KeyContainer{}, false
}
return KeyContainer{
Path: dir,
Name: filepath.Base(dir),
Files: allFiles,
}, true
}
// classifyCertFile парсит один файл — возвращает CertFile если это
// похоже на сертификат.
func classifyCertFile(path string) *CertFile {
lower := strings.ToLower(path)
var format string
switch {
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
format = "cer"
case strings.HasSuffix(lower, ".pem"):
format = "pem"
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
format = "pfx"
default:
return nil
}
cf := &CertFile{
Path: path,
Name: filepath.Base(path),
Format: format,
}
if format == "pfx" {
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
cf.HasPrivateKey = true
return cf
}
data, err := os.ReadFile(path)
if err != nil {
cf.ParseError = "read: " + err.Error()
return cf
}
if len(data) > 32*1024 {
// Странно большой файл для сертификата — режем.
data = data[:32*1024]
}
der := data
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
der = block.Bytes
}
cert, err := x509.ParseCertificate(der)
if err != nil {
cf.ParseError = "x509: " + err.Error()
return cf
}
cf.SubjectCN = cert.Subject.CommonName
cf.IssuerCN = cert.Issuer.CommonName
cf.Serial = cert.SerialNumber.Text(16)
cf.NotBefore = cert.NotBefore
cf.NotAfter = cert.NotAfter
if k := privateKeyUsageEnd(cert.Extensions); !k.IsZero() && k.Before(cf.NotAfter) {
cf.NotAfter = k // подписывать можно до конца срока ключа, а не сертификата
}
cf.INN = extractCertINN(cert)
return cf
}
// privateKeyUsageEnd — конец срока закрытого ключа (расширение PrivateKeyUsagePeriod, 2.5.29.16). В ГОСТ-
// сертификатах ключ подписи действует год–15 месяцев, а сам сертификат — до 15 лет; подписывать после
// срока ключа нельзя. Нулевое время — расширения нет.
func privateKeyUsageEnd(exts []pkix.Extension) time.Time {
for _, e := range exts {
if !e.Id.Equal(asn1.ObjectIdentifier{2, 5, 29, 16}) {
continue
}
var p struct {
NotBefore time.Time `asn1:"generalized,optional,tag:0"`
NotAfter time.Time `asn1:"generalized,optional,tag:1"`
}
if _, err := asn1.Unmarshal(e.Value, &p); err == nil {
return p.NotAfter
}
}
return time.Time{}
}
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
func extractCertINN(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 ""
}
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
// пути. Возвращает Medium или ошибку.
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
abs, err := filepath.Abs(isoPath)
if err != nil {
return Medium{}, fmt.Errorf("ISO путь: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
}
if info.IsDir() {
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
}
id := isoID(abs)
dst := filepath.Join(mediaISODir, id)
if err := os.MkdirAll(dst, 0o755); err != nil {
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
}
if isEmpty, _ := dirEmpty(dst); !isEmpty {
// Уже распакован раньше — просто пересканируем.
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// Защита от zip-slip: 7z x сохраняет пути из архива, поэтому запись «../…» ушла
// бы за dst. Сначала СПИСОК архива (не пишет на диск) — если есть абсолютный путь
// или выход за корень, распаковку не выполняем.
if bad, err := archiveUnsafePath(cctx, abs, password); err != nil {
_ = os.RemoveAll(dst)
return Medium{}, err
} else if bad != "" {
_ = os.RemoveAll(dst)
return Medium{}, fmt.Errorf("архив отклонён: небезопасный путь внутри (%s)", bad)
}
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
args := []string{"x", "-y", "-o" + dst}
if password != "" {
// 7z требует пароль через -p без пробела.
args = append(args, "-p"+password)
} else {
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
args = append(args, "-p-")
}
args = append(args, abs)
cmd := exec.CommandContext(cctx, "7z", args...)
out, err := cmd.CombinedOutput()
if err != nil {
_ = os.RemoveAll(dst)
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
}
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
// archiveUnsafePath — просматривает СПИСОК архива (7z l -slt, без записи на диск)
// и возвращает первый небезопасный путь: абсолютный или выходящий за корень («..»).
// Пустая строка — все пути безопасны.
func archiveUnsafePath(ctx context.Context, archive, password string) (string, error) {
args := []string{"l", "-slt", "-ba"}
if password != "" {
args = append(args, "-p"+password)
} else {
args = append(args, "-p-")
}
args = append(args, archive)
out, err := exec.CommandContext(ctx, "7z", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("7z l: %w / %s", err, strings.TrimSpace(string(out)))
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimRight(line, "\r")
v, ok := strings.CutPrefix(line, "Path = ")
if !ok {
continue
}
p := strings.TrimSpace(v)
if p != "" && unsafeArchivePath(p) {
return p, nil
}
}
return "", nil
}
// unsafeArchivePath — путь из архива опасен: абсолютный (Unix/Windows) или
// выходящий за корень распаковки хотя бы одним компонентом «..».
func unsafeArchivePath(p string) bool {
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) || (len(p) >= 2 && p[1] == ':') {
return true
}
norm := strings.ReplaceAll(p, `\`, "/")
if clean := path.Clean(norm); clean == ".." || strings.HasPrefix(clean, "../") {
return true
}
for _, seg := range strings.Split(norm, "/") {
if seg == ".." {
return true
}
}
return false
}
func isoID(absPath string) string {
h := sha256.Sum256([]byte(absPath))
return hex.EncodeToString(h[:8])
}
func sha1Path(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:6])
}
func dirEmpty(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
names, err := f.Readdirnames(1)
if errors.Is(err, os.ErrInvalid) || err != nil {
return len(names) == 0, nil
}
return len(names) == 0, nil
}
func readISOSource(id string) string {
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func writeISOSource(id, src string) {
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
}