完成证书历史版本恢复核心实现

补充历史证书版本读取、稳定标识、完整证书链恢复、恢复回滚保护及接口安全输出,并同步更新整改任务进度。
This commit is contained in:
xuans 2026-10-03 01:14:12 +08:00
parent 3c59ac5e80
commit 3f6a05d2b9
11 changed files with 864 additions and 102 deletions

View File

@ -0,0 +1,337 @@
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
}

View File

@ -0,0 +1,103 @@
package certificate
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// restoreResource 使用已经校验的内存快照,避免预检之后重新读取另一个历史文件。
// 状态提交失败时恢复原目录和元数据;本地恢复不代表远端服务器已更新。
func (s FileStore) restoreResource(ctx context.Context, domain, certificateType string, resource Resource, expected []string, commit func(SavedResource) error) (SavedResource, error) {
if err := ctx.Err(); err != nil {
return SavedResource{}, err
}
resource, err := ValidateResourceForDomains(resource, expected)
if err != nil {
return SavedResource{}, fmt.Errorf("历史证书不匹配当前域名配置:%w", err)
}
previous, err := s.CurrentDirectory(domain, certificateType)
if err != nil {
return SavedResource{}, err
}
hadPrevious := directoryContainsCertificateFiles(previous)
// 固定保存前的目录。回滚不能仅将旧元数据写入新目录而丢失原文件引用。
saved, saveErr := s.SaveForType(ctx, domain, certificateType, resource)
rollback := func(cause error) (SavedResource, error) {
if saved.Directory == "" {
return SavedResource{}, cause
}
if hadPrevious {
if saved.BackupDirectory == "" {
return SavedResource{}, errors.Join(cause, errors.New("恢复失败且缺少原当前证书归档"))
}
if err := restoreCertificateFiles(previous, saved.BackupDirectory); err != nil {
return SavedResource{}, errors.Join(cause, fmt.Errorf("恢复原当前证书失败:%w", err))
}
if err := rewriteRestoredMetadata(previous); err != nil {
return SavedResource{}, errors.Join(cause, fmt.Errorf("恢复原当前元数据失败:%w", err))
}
}
typeDirectory := filepath.Join(s.Root, directoryName(domain), certificateType)
if err := removeCertificateDirectory(saved.Directory, typeDirectory); err != nil {
return SavedResource{}, errors.Join(cause, fmt.Errorf("清理未提交的恢复版本失败:%w", err))
}
return SavedResource{}, cause
}
if saveErr != nil {
return rollback(saveErr)
}
root, err := openHistoryRoot(s.Root)
if err != nil {
return rollback(err)
}
defer root.Close()
absolute, err := filepath.Abs(saved.Directory)
if err != nil {
return rollback(err)
}
relative, err := filepath.Rel(root.Name(), absolute)
if err != nil {
return rollback(err)
}
actual, err := readHistoryEntry(root, relative, domain, certificateType)
if err != nil {
return rollback(fmt.Errorf("恢复后读取证书失败:%w", err))
}
if _, err := ValidateResourceForDomains(actual.resource, expected); err != nil {
return rollback(fmt.Errorf("恢复后证书校验失败:%w", err))
}
if err := ctx.Err(); err != nil {
return rollback(err)
}
saved.HistoryID = actual.saved.HistoryID
saved.Current = true
if commit != nil {
if err := commit(saved); err != nil {
return rollback(fmt.Errorf("恢复后更新证书状态失败:%w", err))
}
}
return saved, nil
}
// validateCertificateWritePath 拒绝已有路径上的符号链接,不能借恢复写入其他目录。
func validateCertificateWritePath(path string) error {
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
for current := absolute; ; current = filepath.Dir(current) {
info, err := os.Lstat(current)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
return errors.New("证书写入路径必须为真实目录,不能使用符号链接")
}
if filepath.Dir(current) == current {
return nil
}
}
}

View File

@ -0,0 +1,314 @@
package certificate
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func historyTestResource(t *testing.T, names []string, before, after time.Time) Resource {
t.Helper()
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
issuerPublic, issuerPrivate, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
issuer := &x509.Certificate{
SerialNumber: big.NewInt(101), Subject: pkix.Name{CommonName: "历史测试签发机构"},
NotBefore: before, NotAfter: after, IsCA: true, BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
issuerDER, err := x509.CreateCertificate(rand.Reader, issuer, issuer, issuerPublic, issuerPrivate)
if err != nil {
t.Fatal(err)
}
leaf := &x509.Certificate{
SerialNumber: big.NewInt(102), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: before, NotAfter: after, KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, leaf, issuer, public, issuerPrivate)
if err != nil {
t.Fatal(err)
}
key, err := x509.MarshalPKCS8PrivateKey(private)
if err != nil {
t.Fatal(err)
}
return Resource{
Domains: names, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
IssuerCertificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER}),
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}),
}
}
func historyTestStore(t *testing.T) FileStore {
t.Helper()
root := t.TempDir()
return FileStore{Root: filepath.Join(root, "certificates"), BackupRoot: filepath.Join(root, "backups")}
}
func historySave(t *testing.T, store FileStore, certificateType string, names []string) SavedResource {
t.Helper()
resource := historyTestResource(t, names, time.Now().Add(-time.Hour), time.Now().Add(48*time.Hour))
saved, err := store.SaveForType(context.Background(), "example.com", certificateType, resource)
if err != nil {
t.Fatal(err)
}
return saved
}
func historyRead(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}
func historyMetadata(t *testing.T, directory string, change func(*SavedResource)) {
t.Helper()
path := filepath.Join(directory, "metadata.json")
var saved SavedResource
if err := json.Unmarshal(historyRead(t, path), &saved); err != nil {
t.Fatal(err)
}
change(&saved)
data, err := json.Marshal(saved)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestHistoryRestorePreservesChainAndArchives(t *testing.T) {
store := historyTestStore(t)
first := historySave(t, store, "single", []string{"example.com"})
chain := historyRead(t, first.FullChainPath)
key := historyRead(t, first.PrivateKeyPath)
second := historySave(t, store, "single", []string{"example.com"})
items, err := store.ListVersions(context.Background(), "example.com", "single")
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || !items[0].Current || items[1].Current || items[1].HistoryID == "" {
t.Fatalf("历史列表错误:%+v", items)
}
selected, resource, err := store.LoadVersion(context.Background(), "example.com", "single", items[1].HistoryID)
if err != nil {
t.Fatal(err)
}
combined := append(append([]byte(nil), resource.Certificate...), resource.IssuerCertificate...)
if selected.Version != first.Version || !bytes.Equal(combined, chain) {
t.Fatal("历史读取必须保留完整链")
}
result, err := store.RestoreVersion(context.Background(), "example.com", "single", items[1].HistoryID)
if err != nil {
t.Fatal(err)
}
if result.Version == first.Version || result.Version == second.Version || !result.Current || result.ChainLength != 2 {
t.Fatalf("恢复必须产生新的当前版本:%+v", result)
}
if !bytes.Equal(historyRead(t, result.FullChainPath), chain) || !bytes.Equal(historyRead(t, result.PrivateKeyPath), key) {
t.Fatal("恢复后完整链或私钥发生变化")
}
if bytes.Count(historyRead(t, result.CertificatePath), []byte("BEGIN CERTIFICATE")) != 1 {
t.Fatal("恢复后的叶子证书文件不应包含完整证书链")
}
entries, err := os.ReadDir(filepath.Dir(result.Directory))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != result.Version {
t.Fatal("正式目录只能保留当前版本")
}
items, err = store.ListVersions(context.Background(), "example.com", "single")
if err != nil || len(items) != 3 {
t.Fatalf("恢复不得丢失原历史:%+v,%v", items, err)
}
if items[0].HistoryID != result.HistoryID || !items[0].Current {
t.Fatal("恢复后的当前历史标识不稳定")
}
}
func TestHistoryExpiredAndFutureVersionsRemainVisibleButCannotRestore(t *testing.T) {
for _, future := range []bool{false, true} {
name := "已过期"
if future {
name = "未生效"
}
t.Run(name, func(t *testing.T) {
store := historyTestStore(t)
before, after := time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour)
if future {
before, after = time.Now().Add(time.Hour), time.Now().Add(48*time.Hour)
}
old, err := store.SaveForType(context.Background(), "example.com", "single", historyTestResource(t, []string{"example.com"}, before, after))
if err != nil {
t.Fatal(err)
}
current := historySave(t, store, "single", []string{"example.com"})
original := historyRead(t, current.FullChainPath)
items, err := store.ListVersions(context.Background(), "example.com", "single")
if err != nil || len(items) != 2 {
t.Fatalf("不可恢复的历史仍需展示:%v", err)
}
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", old.Version); err == nil {
t.Fatal("不允许恢复有效期外的证书")
}
if !bytes.Equal(original, historyRead(t, current.FullChainPath)) {
t.Fatal("拒绝恢复不应改变当前证书")
}
})
}
}
func TestHistoryRejectsAmbiguousSANVersionAndUsesStableIdentity(t *testing.T) {
store := historyTestStore(t)
historySave(t, store, "san", []string{"example.com", "old.example.com"})
current := historySave(t, store, "san", []string{"example.com", "new.example.com"})
// 兼容旧版本按不同 SAN 目录独立编号产生的真实历史歧义。
historyMetadata(t, current.BackupDirectory, func(saved *SavedResource) { saved.Version = current.Version })
items, err := store.ListVersions(context.Background(), "example.com", "san")
if err != nil || len(items) != 2 {
t.Fatalf("读取同号历史失败:%v", err)
}
if items[0].HistoryID == items[1].HistoryID {
t.Fatal("不同 SAN 历史必须具有不同标识")
}
if _, _, err := store.LoadVersion(context.Background(), "example.com", "san", current.Version); err == nil || !strings.Contains(err.Error(), "歧义") {
t.Fatalf("不能默默选取同名版本:%v", err)
}
for _, item := range items {
selected, _, err := store.LoadVersion(context.Background(), "example.com", "san", item.HistoryID)
if err != nil || selected.CertificateIdentifier != item.CertificateIdentifier {
t.Fatalf("历史标识未精确选择:%v", err)
}
}
}
func TestHistoryMetadataCannotOverrideFilesOrValidity(t *testing.T) {
store := historyTestStore(t)
first := historySave(t, store, "single", []string{"example.com"})
current := historySave(t, store, "single", []string{"example.com"})
historyMetadata(t, current.BackupDirectory, func(saved *SavedResource) {
saved.Directory = "/etc"
saved.PrivateKeyPath = "/etc/shadow"
saved.BackupDirectory = "/"
saved.FingerprintSHA256 = "伪造"
saved.NotAfter = time.Now().Add(999 * 24 * time.Hour)
})
saved, _, err := store.LoadVersion(context.Background(), "example.com", "single", first.Version)
if err != nil {
t.Fatal(err)
}
if saved.Directory != current.BackupDirectory || saved.PrivateKeyPath == "/etc/shadow" || saved.BackupDirectory != "" || saved.FingerprintSHA256 == "伪造" || !saved.NotAfter.Equal(first.NotAfter) {
t.Fatalf("不应相信归档路径或伪造元数据:%+v", saved)
}
}
func TestHistoryRejectsInvalidSelectorAndCancellation(t *testing.T) {
store := historyTestStore(t)
current := historySave(t, store, "single", []string{"example.com"})
for _, selector := range []string{"../key.pem", "", "history-xyz", "history-" + strings.Repeat("a", 64), "20260830-V99.0"} {
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", selector); err == nil {
t.Fatalf("非法或缺失历史不应恢复:%q", selector)
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.ListVersions(ctx, "example.com", "single"); !errors.Is(err, context.Canceled) {
t.Fatalf("未传播取消:%v", err)
}
if _, err := store.RestoreVersion(ctx, "example.com", "single", current.Version); !errors.Is(err, context.Canceled) {
t.Fatalf("未传播取消:%v", err)
}
}
func TestHistoryRejectsSymlinkArchiveAndFiles(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("本轮只验收 Linux 符号链接安全")
}
for _, target := range []string{"archive", "key.pem", "metadata.json"} {
t.Run(target, func(t *testing.T) {
store := historyTestStore(t)
historySave(t, store, "single", []string{"example.com"})
current := historySave(t, store, "single", []string{"example.com"})
path := filepath.Join(current.BackupDirectory, target)
if target == "archive" {
path = current.BackupDirectory
}
moved := path + "-original"
if err := os.Rename(path, moved); err != nil {
t.Fatal(err)
}
if err := os.Symlink(moved, path); err != nil {
t.Fatal(err)
}
if _, err := store.ListVersions(context.Background(), "example.com", "single"); err == nil {
t.Fatal("不允许通过符号链接读取历史证书")
}
})
}
}
func TestServiceHistoryRestoreRejectsChangedSANBeforeWriting(t *testing.T) {
root := t.TempDir()
cfg := certificateTestConfig(root)
store := FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup}
first := historySave(t, store, "san", []string{"example.com", "old.example.com"})
current := historySave(t, store, "san", []string{"example.com", "new.example.com"})
before := historyRead(t, current.FullChainPath)
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSAN, []string{"new.example.com"}, true, domain.ACMEEnvironmentStaging, "cloudflare")
if err != nil {
t.Fatal(err)
}
repository := &serviceRepository{item: item}
service := NewService(cfg, repository, nil, nil, nil)
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); err == nil {
t.Fatal("历史证书必须覆盖当前配置的全部域名")
}
if repository.updateCalls != 0 || !bytes.Equal(before, historyRead(t, current.FullChainPath)) {
t.Fatal("域名预检失败不能修改文件或数据库")
}
}
func TestHistoryFiltersOtherCertificateTypes(t *testing.T) {
store := historyTestStore(t)
historySave(t, store, "single", []string{"example.com"})
historySave(t, store, "single", []string{"example.com"})
historySave(t, store, "san", []string{"example.com", "www.example.com"})
historySave(t, store, "san", []string{"example.com", "api.example.com"})
for _, certificateType := range []string{"single", "san"} {
items, err := store.ListVersions(context.Background(), "example.com", certificateType)
if err != nil || len(items) != 2 {
t.Fatalf("证书类型历史隔离失败:%s,%v", certificateType, err)
}
for _, item := range items {
if item.CertificateType != certificateType {
t.Fatal("历史列表混入其他类型")
}
}
}
}

View File

@ -44,6 +44,8 @@ type SavedResource struct {
CertificateType string `json:"certificate_type,omitempty"`
CertificateIdentifier string `json:"certificate_identifier,omitempty"`
Version string `json:"version,omitempty"`
HistoryID string `json:"history_id,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"saved_at"`
SANs []string `json:"sans,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
@ -224,6 +226,13 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
if err != nil {
return SavedResource{}, err
}
// SAN 组合变化或系统时间回拨时,新当前版本仍必须排在旧版本之后。
if activeDirectory != "" && !newerCertificateDirectory(version, filepath.Base(activeDirectory)) {
date, major, minor, valid := parseCertificateVersion(filepath.Base(activeDirectory))
if valid {
version = fmt.Sprintf("%s-V%d.%d", date, major, minor+1)
}
}
currentDirectory = filepath.Join(directory, version)
}
protected := false
@ -234,13 +243,26 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
}
}
backupDirectory := ""
if err := validateCertificateWritePath(currentDirectory); err != nil {
return SavedResource{}, err
}
if activeDirectory != "" {
if err := validateCertificateWritePath(activeDirectory); err != nil {
return SavedResource{}, err
}
backupDirectory, err = s.backupExisting(activeDirectory, primary)
if err != nil {
return SavedResource{}, err
}
}
if err := os.MkdirAll(currentDirectory, 0o700); err != nil {
if err := os.MkdirAll(filepath.Dir(currentDirectory), 0o700); err != nil {
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
}
if certificateType != "" {
if err := os.Mkdir(currentDirectory, 0o700); err != nil {
return SavedResource{}, fmt.Errorf("创建新证书版本失败:%w", err)
}
} else if err := os.MkdirAll(currentDirectory, 0o700); err != nil {
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
}
fullChain := resource.Certificate
@ -281,7 +303,7 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
}
if !s.RetainPrevious && !protected && certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory {
if err := removeCertificateDirectory(activeDirectory, typeDirectory); err != nil {
return SavedResource{}, fmt.Errorf("清理旧当前证书目录失败:%w", err)
return result, fmt.Errorf("清理旧当前证书目录失败:%w", err)
}
}
return result, nil
@ -715,97 +737,31 @@ func (s FileStore) LoadCurrent(ctx context.Context, domain, certificateType stri
return saved, resource, nil
}
// LoadVersion 仅允许读取当前使用的版本,保留该方法用于兼容已有接口。
// LoadVersion 读取可唯一识别的当前或历史版本,恢复前仍需校验当前域名配置。
func (s FileStore) LoadVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, Resource, error) {
if err := ctx.Err(); err != nil {
return SavedResource{}, Resource{}, err
}
if err := validateCertificateVersion(version); err != nil {
return SavedResource{}, Resource{}, err
}
directory, err := s.CurrentDirectory(domain, certificateType)
if err != nil {
return SavedResource{}, Resource{}, err
}
if filepath.Base(directory) != version {
return SavedResource{}, Resource{}, errors.New("指定版本不是当前使用的证书版本")
}
certificateData, err := os.ReadFile(filepath.Join(directory, "cert.pem"))
if err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("读取当前证书失败:%w", err)
}
fullChainData, err := os.ReadFile(filepath.Join(directory, "fullchain.pem"))
if err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("读取当前完整证书链失败:%w", err)
}
privateKeyData, err := os.ReadFile(filepath.Join(directory, "key.pem"))
if err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("读取当前私钥失败:%w", err)
}
var saved SavedResource
metadata, err := os.ReadFile(filepath.Join(directory, "metadata.json"))
if err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("读取当前证书元数据失败:%w", err)
}
if err := json.Unmarshal(metadata, &saved); err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("解析当前证书元数据失败:%w", err)
}
if saved.Version != version {
return SavedResource{}, Resource{}, errors.New("当前证书版本标识不一致")
}
domains := saved.SANs
if len(domains) == 0 {
domains = []string{domain}
}
resource, err := ValidateResourceForDomains(Resource{
Domains: domains,
Certificate: certificateData,
IssuerCertificate: fullChainData,
PrivateKey: privateKeyData,
}, domains)
if err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("当前证书校验失败:%w", err)
}
saved.Directory = directory
saved.CertificatePath = filepath.Join(directory, "cert.pem")
saved.FullChainPath = filepath.Join(directory, "fullchain.pem")
saved.PrivateKeyPath = filepath.Join(directory, "key.pem")
saved.BackupDirectory = ""
resource.IssuerCertificate = nil
return saved, resource, nil
return s.loadHistoryVersion(ctx, domain, certificateType, version)
}
// RestoreVersion 已停用,正式证书目录只保留当前使用的版本。
// RestoreVersion 从本地历史归档创建新的当前版本,不执行远端部署。
func (s FileStore) RestoreVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, error) {
if err := ctx.Err(); err != nil {
_, resource, err := s.LoadVersion(ctx, domain, certificateType, version)
if err != nil {
return SavedResource{}, err
}
if err := validateCertificateVersion(version); err != nil {
return SavedResource{}, err
}
return SavedResource{}, errors.New("当前仅保留当前使用的证书版本,不支持历史版本恢复")
return s.restoreResource(ctx, domain, certificateType, resource, resource.Domains, nil)
}
// ListVersions 返回当前唯一使用的证书版本,保留该方法用于兼容已有接口。
// ListVersions 列出当前版本和本地归档,过期版本可展示但不可恢复。
func (s FileStore) ListVersions(ctx context.Context, domain, certificateType string) ([]SavedResource, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
directory, err := s.CurrentDirectory(domain, certificateType)
entries, err := s.historyEntries(ctx, domain, certificateType)
if err != nil {
return nil, err
}
if _, statErr := os.Stat(filepath.Join(directory, "metadata.json")); statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
return []SavedResource{}, nil
}
return nil, fmt.Errorf("读取当前证书版本失败:%w", statErr)
items := make([]SavedResource, 0, len(entries))
for _, entry := range entries {
items = append(items, entry.saved)
}
saved, _, err := s.LoadCurrent(ctx, domain, certificateType)
if err != nil {
return nil, err
}
return []SavedResource{saved}, nil
return items, nil
}
func validateCertificateVersion(version string) error {

View File

@ -65,7 +65,7 @@ func TestFileStoreUsesSingleDatedCurrentVersion(t *testing.T) {
if err != nil {
t.Fatalf("读取当前证书版本失败:%v", err)
}
if len(versions) != 1 || versions[0].Version != second.Version {
if len(versions) != 2 || versions[0].Version != second.Version || !versions[0].Current || versions[1].Version != first.Version || versions[1].Current {
t.Fatalf("当前证书版本列表错误:%+v", versions)
}
metadata, loaded, err := store.LoadCurrent(context.Background(), "example.com", "single")
@ -147,10 +147,10 @@ func TestRollbackSavedResourceClearsNewCertificateFiles(t *testing.T) {
}
}
func TestFileStoreDoesNotRestoreHistoricalVersion(t *testing.T) {
func TestFileStoreRejectsMissingHistoricalVersion(t *testing.T) {
store := FileStore{Root: t.TempDir()}
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", "20260830-V1.0"); err == nil {
t.Fatal("仅保留当前版本时不应支持历史版本恢复")
t.Fatal("不存在的历史证书版本不应恢复成功")
}
}
@ -187,7 +187,7 @@ func TestFileStoreUsesStableSANIdentifierAndRemovesPreviousIdentifier(t *testing
if second.CertificateIdentifier == first.CertificateIdentifier {
t.Fatal("different SAN sets should use different identifiers")
}
if second.Version != "20260830-V1.0" {
if second.Version != "20260830-V1.1" {
t.Fatalf("unexpected second SAN version: %s", second.Version)
}
if _, err := os.Stat(first.Directory); !errors.Is(err, os.ErrNotExist) {

View File

@ -535,20 +535,12 @@ func (s *Service) RestoreVersion(ctx context.Context, domainID, version string)
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
return IssueResult{}, fmt.Errorf("历史证书不匹配当前域名配置:%w", err)
}
saved, err := s.store.RestoreVersion(ctx, item.Name, string(item.CertificateType), strings.TrimSpace(version))
saved, err := s.store.restoreResource(ctx, item.Name, string(item.CertificateType), resource, domains, func(saved SavedResource) error {
return s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter)
})
if err != nil {
return IssueResult{}, err
}
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
return IssueResult{}, fmt.Errorf("恢复后证书校验失败:%w", err)
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter); err != nil {
rollbackErr := RollbackSavedResource(context.Background(), saved)
if rollbackErr != nil {
return IssueResult{}, errors.Join(err, rollbackErr)
}
return IssueResult{}, fmt.Errorf("certificate state update failed after restore: %w", err)
}
saved.SANs = append([]string(nil), domains...)
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
}

View File

@ -22,9 +22,10 @@ import (
)
type serviceRepository struct {
item domain.Domain
states []string
updateErr error
item domain.Domain
states []string
updateErr error
updateCalls int
}
func (r *serviceRepository) GetByID(_ context.Context, _ string) (domain.Domain, error) {
@ -32,6 +33,7 @@ func (r *serviceRepository) GetByID(_ context.Context, _ string) (domain.Domain,
}
func (r *serviceRepository) UpdateCertificateState(_ context.Context, _ string, status string, expiresAt *time.Time) error {
r.updateCalls++
if status == "valid" && r.updateErr != nil {
return r.updateErr
}
@ -301,8 +303,11 @@ func TestServiceRestoreVersionRollsBackFilesWhenStateUpdateFails(t *testing.T) {
}
repository := &serviceRepository{item: item, updateErr: errors.New("simulated state update failure")}
service := NewService(cfg, repository, nil, nil, nil)
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); err == nil {
t.Fatal("state update failure should be returned")
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); !errors.Is(err, repository.updateErr) {
t.Fatalf("必须实际到达状态更新并返回注入的错误:%v", err)
}
if repository.updateCalls != 1 {
t.Fatalf("恢复未实际到达状态更新:调用次数=%d", repository.updateCalls)
}
afterCertificate, err := os.ReadFile(second.CertificatePath)
if err != nil {

View File

@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -31,6 +32,34 @@ type certificatePathRequest struct {
IssuerBundlePath string `json:"issuer_bundle_path"`
}
// certificateVersionResponse 只公开证书历史元数据,不公开服务器本地路径。
type certificateVersionResponse struct {
Domain string `json:"domain"`
CertificateType string `json:"certificate_type,omitempty"`
CertificateIdentifier string `json:"certificate_identifier,omitempty"`
Version string `json:"version,omitempty"`
HistoryID string `json:"history_id,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"saved_at"`
SANs []string `json:"sans,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
FingerprintSHA256 string `json:"fingerprint_sha256,omitempty"`
ChainLength int `json:"chain_length,omitempty"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
}
func publicCertificateVersion(item certificate.SavedResource) certificateVersionResponse {
return certificateVersionResponse{
Domain: item.Domain, CertificateType: item.CertificateType,
CertificateIdentifier: item.CertificateIdentifier, Version: item.Version,
HistoryID: item.HistoryID, Current: item.Current, SavedAt: item.SavedAt,
SANs: append([]string(nil), item.SANs...), SerialNumber: item.SerialNumber,
FingerprintSHA256: item.FingerprintSHA256, ChainLength: item.ChainLength,
NotBefore: item.NotBefore, NotAfter: item.NotAfter,
}
}
func (s *Server) listCertificates(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
@ -90,7 +119,11 @@ func (s *Server) listCertificateVersions(c *gin.Context) {
s.writeError(c, certificateErrorStatus(err), "查询证书历史版本失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
publicItems := make([]certificateVersionResponse, 0, len(items))
for _, item := range items {
publicItems = append(publicItems, publicCertificateVersion(item))
}
c.JSON(http.StatusOK, gin.H{"items": publicItems})
}
func (s *Server) validateCertificate(c *gin.Context) {

View File

@ -47,7 +47,7 @@ func TestCertificateReadAPIs(t *testing.T) {
NotAfter: expiresAt,
},
},
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000"}},
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000", HistoryID: "history-test", Directory: "/private/cert", PrivateKeyPath: "/private/key.pem"}},
}
repo := &stubDomainRepository{items: []domain.Domain{{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}}
repo.loaded = repo.items[0]
@ -73,6 +73,9 @@ func TestCertificateReadAPIs(t *testing.T) {
if versions.Code != http.StatusOK || !strings.Contains(versions.Body.String(), "20260827-120000.000000000") {
t.Fatalf("证书历史版本响应错误:%d %s", versions.Code, versions.Body.String())
}
if strings.Contains(versions.Body.String(), "private_key_path") || strings.Contains(versions.Body.String(), "/private/key.pem") || strings.Contains(versions.Body.String(), "directory") {
t.Fatalf("证书历史接口不应公开本地路径:%s", versions.Body.String())
}
}
func TestCertificateListHidesReaderErrors(t *testing.T) {

View File

@ -1,5 +1,17 @@
# Scert 整改任务执行清单
## 2026年10月2日21:40 历史证书与恢复开发批次
需求沿用已确认的 P1-02/P2-03,不缩减历史版本恢复范围。修改前基线 3c59ac5 已提交推送;本轮不处理 Windows,不部署生产,不增加远端备份。
- [~] 复用本地证书归档读取历史版本,校验真实证书元数据,拒绝路径逃逸及符号链接;正式目录仍只保留当前版本及受保护的管理入口引用。
- [ ] 解决 SAN 不同域名组合的同名版本歧义,提供稳定的历史标识;历史列表允许展示过期版本,但恢复拒绝过期、未生效、域名不匹配及密钥错误。
- [ ] 恢复保留完整证书链,产生新的当前版本;状态更新或恢复后校验失败时恢复原当前文件及元数据。
- [ ] 补齐真实文件存储与服务层测试,证明状态更新失败用例确实到达数据库更新,而不是提前拒绝恢复的假通过。
- [ ] 接入持久化后台任务、执行前权限、幂等与前端历史操作,更新接口说明。
- [ ] 执行 Linux 原生自动回归并记录证据,再安排统一正式部署验收。
## 2026年10月2日21:30 手动续期后台化回归完成,等待复审
需求沿用已确认的 P1-05/P2-03:保留管理证书和全部业务域名的原续期调度,不缩减行为;先开发、自动回归,再统一部署。修改前基线 4ab3f93 已提交推送。

View File

@ -1,5 +1,12 @@
# Scert 整改、合并与优化清单
## 2026年10月2日21:40 当前开发进度:历史版本与恢复
- [~] P1-02:已确认真实存储仍不支持历史恢复,开始补齐本地历史归档读取、恢复校验与失败回滚,不以模拟接口测试代替实现。
- [ ] P2-03 配套:恢复后台任务、实时权限复核及前端操作待接入。
- [!] 本批尚未回归或部署;原升级、通知重置、三库首次 Web、备份恢复、监控与 P3 均保留。
## 2026年10月2日21:30 当前开发进度:手动续期后台化完成待复审
- [x] P1-05/P2-03 子项:全局手动续期持久化为系统任务后返回 202,保留管理证书及业务域名原调度;支持幂等找回、活动任务去重、取消传播和执行前超级管理员复核。