315 lines
12 KiB
Go
315 lines
12 KiB
Go
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("历史列表混入其他类型")
|
||
}
|
||
}
|
||
}
|
||
}
|