package certificate import ( "bytes" "context" "crypto/sha256" "crypto/x509" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" ) type historyEntry struct { saved SavedResource resource Resource } var errHistoryOtherType = errors.New("归档属于其他证书类型") // openHistoryRoot 拒绝符号链接目录,并将所有后续读取限制在已打开的存储根内。 func openHistoryRoot(path string) (*os.Root, error) { if strings.TrimSpace(path) == "" { return nil, errors.New("证书存储根目录不能为空") } absolute, err := filepath.Abs(path) if err != nil { return nil, err } for current := absolute; ; current = filepath.Dir(current) { info, err := os.Lstat(current) if err != nil { return nil, err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("证书存储路径必须为真实目录") } if filepath.Dir(current) == current { break } } return os.OpenRoot(absolute) } func openHistoryDirectory(root *os.Root, relative string) (*os.Root, error) { if !filepath.IsLocal(relative) { return nil, errors.New("证书历史路径超出存储目录") } current := "" for _, part := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) { current = filepath.Join(current, part) info, err := root.Lstat(current) if err != nil { return nil, err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("证书历史目录不允许符号链接") } } return root.OpenRoot(relative) } func readHistoryFile(root *os.Root, name string) ([]byte, error) { info, err := root.Lstat(name) if err != nil { return nil, err } const maximumFileSize = 4 << 20 if !info.Mode().IsRegular() || info.Size() > maximumFileSize { return nil, errors.New("证书历史文件必须为大小受限的普通文件") } file, err := root.Open(name) if err != nil { return nil, err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, maximumFileSize+1)) if err != nil { return nil, err } if len(data) > maximumFileSize { return nil, errors.New("证书历史文件超过大小限制") } return data, nil } func readHistoryEntry(root *os.Root, relative, domain, certificateType string) (historyEntry, error) { directory, err := openHistoryDirectory(root, relative) if err != nil { return historyEntry{}, err } defer directory.Close() metadata, err := readHistoryFile(directory, "metadata.json") if err != nil { return historyEntry{}, err } var saved SavedResource if err := json.Unmarshal(metadata, &saved); err != nil { return historyEntry{}, fmt.Errorf("解析历史证书元数据失败:%w", err) } if saved.CertificateType != certificateType { return historyEntry{}, errHistoryOtherType } if directoryName(saved.Domain) != directoryName(domain) { return historyEntry{}, errors.New("历史证书归属域名不匹配") } if saved.Version != "" { if err := validateCertificateVersion(saved.Version); err != nil { return historyEntry{}, err } } certificateData, err := readHistoryFile(directory, "cert.pem") if err != nil { return historyEntry{}, err } chain, err := readHistoryFile(directory, "fullchain.pem") if err != nil { return historyEntry{}, err } key, err := readHistoryFile(directory, "key.pem") if err != nil { return historyEntry{}, err } names := saved.SANs if len(names) == 0 { names = []string{domain} } chainLeafData, issuerData, err := splitCertificateChain(chain) if err != nil { return historyEntry{}, err } resource, err := ValidateResource(Resource{Domains: names, Certificate: certificateData, IssuerCertificate: issuerData, PrivateKey: key}) if err != nil { return historyEntry{}, err } leaf, err := parseLeafCertificate(certificateData) if err != nil { return historyEntry{}, err } chainLeaf, err := parseLeafCertificate(chainLeafData) if err != nil || !bytes.Equal(leaf.Raw, chainLeaf.Raw) { return historyEntry{}, errors.New("历史证书与完整链的叶子证书不一致") } for _, name := range append(append([]string(nil), resource.Domains...), domain) { probe := strings.Replace(name, "*.", "scert-wildcard-probe.", 1) if err := leaf.VerifyHostname(probe); err != nil { return historyEntry{}, fmt.Errorf("历史证书不覆盖域名 %s:%w", name, err) } } remaining := chain chainLength := 0 for len(bytes.TrimSpace(remaining)) > 0 { block, rest := pem.Decode(remaining) if block == nil || block.Type != "CERTIFICATE" { return historyEntry{}, errors.New("历史证书完整链格式无效") } if _, err := x509.ParseCertificate(block.Bytes); err != nil { return historyEntry{}, fmt.Errorf("解析历史证书链失败:%w", err) } chainLength++ remaining = rest } // 元数据中的路径、指纹、有效期均不可信,必须从实际文件重新生成。 saved.Domain = domain saved.SANs = append([]string(nil), resource.Domains...) saved.NotBefore, saved.NotAfter = leaf.NotBefore, leaf.NotAfter saved.SerialNumber = leaf.SerialNumber.Text(16) saved.FingerprintSHA256 = fmt.Sprintf("%X", sha256.Sum256(leaf.Raw)) saved.ChainLength = chainLength saved.CertificateIdentifier = "" if certificateType == "san" { saved.CertificateIdentifier = sanCertificateIdentifier(resource.Domains) } saved.Directory = filepath.Join(root.Name(), relative) saved.CertificatePath = filepath.Join(saved.Directory, "cert.pem") saved.FullChainPath = filepath.Join(saved.Directory, "fullchain.pem") saved.PrivateKeyPath = filepath.Join(saved.Directory, "key.pem") saved.BackupDirectory = "" saved.Current = false identity := fmt.Sprintf("%s\n%s\n%s\n%s\n%x", directoryName(domain), certificateType, saved.Version, strings.Join(resource.Domains, ","), sha256.Sum256(chain)) saved.HistoryID = fmt.Sprintf("history-%x", sha256.Sum256([]byte(identity))) return historyEntry{saved: saved, resource: resource}, nil } func splitCertificateChain(data []byte) ([]byte, []byte, error) { block, rest := pem.Decode(data) if block == nil || block.Type != "CERTIFICATE" { return nil, nil, errors.New("历史证书完整链缺少叶子证书") } if _, err := x509.ParseCertificate(block.Bytes); err != nil { return nil, nil, fmt.Errorf("解析历史证书叶子失败:%w", err) } issuer := append([]byte(nil), rest...) if len(bytes.TrimSpace(issuer)) == 0 { issuer = nil } return pem.EncodeToMemory(block), issuer, nil } func (s FileStore) historyEntries(ctx context.Context, domain, certificateType string) ([]historyEntry, error) { if err := ctx.Err(); err != nil { return nil, err } current, err := s.CurrentDirectory(domain, certificateType) if err != nil { return nil, err } domain = strings.ToLower(strings.TrimSpace(domain)) certificateType = strings.ToLower(strings.TrimSpace(certificateType)) entries := make([]historyEntry, 0) seen := make(map[string]bool) appendEntry := func(entry historyEntry) { if !seen[entry.saved.HistoryID] { seen[entry.saved.HistoryID] = true entries = append(entries, entry) } } root, err := openHistoryRoot(s.Root) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } if err == nil { defer root.Close() absolute, err := filepath.Abs(current) if err != nil { return nil, err } relative, err := filepath.Rel(root.Name(), absolute) if err != nil { return nil, err } entry, err := readHistoryEntry(root, relative, domain, certificateType) if err == nil { entry.saved.Current = true appendEntry(entry) } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("读取当前证书版本失败:%w", err) } } if strings.TrimSpace(s.BackupRoot) == "" { return entries, nil } backups, err := openHistoryRoot(s.BackupRoot) if errors.Is(err, os.ErrNotExist) { return entries, nil } if err != nil { return nil, err } defer backups.Close() directory, err := openHistoryDirectory(backups, directoryName(domain)) if errors.Is(err, os.ErrNotExist) { return entries, nil } if err != nil { return nil, err } defer directory.Close() file, err := directory.Open(".") if err != nil { return nil, err } children, err := file.ReadDir(-1) _ = file.Close() if err != nil { return nil, err } for _, child := range children { if err := ctx.Err(); err != nil { return nil, err } if child.Type()&os.ModeSymlink != 0 { return nil, errors.New("历史证书归档不允许符号链接") } if !child.IsDir() { continue } entry, err := readHistoryEntry(directory, child.Name(), domain, certificateType) if errors.Is(err, errHistoryOtherType) { continue } if err != nil { return nil, fmt.Errorf("读取历史证书归档失败:%w", err) } appendEntry(entry) } sort.SliceStable(entries, func(i, j int) bool { if entries[i].saved.Current != entries[j].saved.Current { return entries[i].saved.Current } if !entries[i].saved.SavedAt.Equal(entries[j].saved.SavedAt) { return entries[i].saved.SavedAt.After(entries[j].saved.SavedAt) } return entries[i].saved.HistoryID < entries[j].saved.HistoryID }) return entries, nil } func (s FileStore) loadHistoryVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, Resource, error) { if strings.HasPrefix(version, "history-") { digest, err := hex.DecodeString(strings.TrimPrefix(version, "history-")) if err != nil || len(digest) != sha256.Size { return SavedResource{}, Resource{}, errors.New("证书历史标识无效") } } else if err := validateCertificateVersion(version); err != nil { return SavedResource{}, Resource{}, err } entries, err := s.historyEntries(ctx, domain, certificateType) if err != nil { return SavedResource{}, Resource{}, err } var selected *historyEntry for index := range entries { entry := &entries[index] if entry.saved.HistoryID != version && entry.saved.Version != version { continue } if selected != nil { return SavedResource{}, Resource{}, errors.New("证书版本号存在歧义,请使用历史标识恢复") } selected = entry } if selected == nil { return SavedResource{}, Resource{}, errors.New("未找到指定证书历史版本") } resource, err := ValidateResourceForDomains(selected.resource, selected.resource.Domains) if err != nil { return SavedResource{}, Resource{}, fmt.Errorf("历史证书不可恢复:%w", err) } return selected.saved, resource, nil }