diff --git a/backend/internal/certificate/history.go b/backend/internal/certificate/history.go new file mode 100644 index 0000000..8755257 --- /dev/null +++ b/backend/internal/certificate/history.go @@ -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 +} diff --git a/backend/internal/certificate/history_restore.go b/backend/internal/certificate/history_restore.go new file mode 100644 index 0000000..ac24ccd --- /dev/null +++ b/backend/internal/certificate/history_restore.go @@ -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 + } + } +} diff --git a/backend/internal/certificate/history_test.go b/backend/internal/certificate/history_test.go new file mode 100644 index 0000000..234265d --- /dev/null +++ b/backend/internal/certificate/history_test.go @@ -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("历史列表混入其他类型") + } + } + } +} diff --git a/backend/internal/certificate/lifecycle.go b/backend/internal/certificate/lifecycle.go index 615f47a..4ef0b97 100644 --- a/backend/internal/certificate/lifecycle.go +++ b/backend/internal/certificate/lifecycle.go @@ -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 { diff --git a/backend/internal/certificate/lifecycle_version_test.go b/backend/internal/certificate/lifecycle_version_test.go index af34f15..d919448 100644 --- a/backend/internal/certificate/lifecycle_version_test.go +++ b/backend/internal/certificate/lifecycle_version_test.go @@ -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) { diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 78f6a31..0dca5b8 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -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 } diff --git a/backend/internal/certificate/service_test.go b/backend/internal/certificate/service_test.go index ad83ed7..b62faf0 100644 --- a/backend/internal/certificate/service_test.go +++ b/backend/internal/certificate/service_test.go @@ -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 { diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index 8952e47..9f77d4a 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -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) { diff --git a/backend/internal/httpapi/certificate_api_test.go b/backend/internal/httpapi/certificate_api_test.go index 8a8e4f7..7eb705e 100644 --- a/backend/internal/httpapi/certificate_api_test.go +++ b/backend/internal/httpapi/certificate_api_test.go @@ -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) { diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 7ad67da..0323c44 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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 已提交推送。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 1e83c5c..9b2594d 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -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,保留管理证书及业务域名原调度;支持幂等找回、活动任务去重、取消传播和执行前超级管理员复核。