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// через 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 [-p] — рекурсивное извлечение. 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) }