Scert/backend/internal/certificate/history_test.go
xuans 3f6a05d2b9 完成证书历史版本恢复核心实现
补充历史证书版本读取、稳定标识、完整证书链恢复、恢复回滚保护及接口安全输出,并同步更新整改任务进度。
2026-10-03 01:14:12 +08:00

315 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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("历史列表混入其他类型")
}
}
}
}