282 lines
8.5 KiB
Go
282 lines
8.5 KiB
Go
package certificate
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ValidationResult 保存证书校验后的关键元数据。
|
||
type ValidationResult struct {
|
||
Domains []string `json:"domains"`
|
||
SerialNumber string `json:"serial_number"`
|
||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||
NotBefore time.Time `json:"not_before"`
|
||
NotAfter time.Time `json:"not_after"`
|
||
ChainLength int `json:"chain_length"`
|
||
}
|
||
|
||
// LocalValidator 校验本地证书、私钥和证书链。
|
||
type LocalValidator struct {
|
||
Roots *x509.CertPool
|
||
Now func() time.Time
|
||
VerifyChain bool
|
||
}
|
||
|
||
// ValidateFiles 校验证书文件、私钥文件以及期望覆盖的域名。
|
||
func (v LocalValidator) ValidateFiles(ctx context.Context, certificatePath, fullChainPath, privateKeyPath string, expectedDomains []string) (ValidationResult, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
certificateData, err := os.ReadFile(certificatePath)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("读取证书文件失败:%w", err)
|
||
}
|
||
chainData := certificateData
|
||
if strings.TrimSpace(fullChainPath) != "" {
|
||
chainData, err = os.ReadFile(fullChainPath)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("读取完整证书链失败:%w", err)
|
||
}
|
||
}
|
||
privateKeyData, err := os.ReadFile(privateKeyPath)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("读取私钥文件失败:%w", err)
|
||
}
|
||
pair, err := tls.X509KeyPair(certificateData, privateKeyData)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("证书与私钥不匹配:%w", err)
|
||
}
|
||
chainPair, err := tls.X509KeyPair(chainData, privateKeyData)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("证书与私钥不匹配:%w", err)
|
||
}
|
||
if !bytes.Equal(pair.Certificate[0], chainPair.Certificate[0]) {
|
||
return ValidationResult{}, errors.New("证书文件与完整证书链的叶子证书不一致")
|
||
}
|
||
return v.validatePEM(ctx, chainData, expectedDomains)
|
||
}
|
||
|
||
// ValidatePEM 校验内存中的证书链和期望域名。
|
||
func (v LocalValidator) ValidatePEM(ctx context.Context, chainData []byte, expectedDomains []string) (ValidationResult, error) {
|
||
return v.validatePEM(ctx, chainData, expectedDomains)
|
||
}
|
||
|
||
func (v LocalValidator) validatePEM(ctx context.Context, chainData []byte, expectedDomains []string) (ValidationResult, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
certificates, err := parseCertificates(chainData)
|
||
if err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
leaf := certificates[0]
|
||
wanted, err := normalizeValidationNames(expectedDomains)
|
||
if err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
if v.Now != nil {
|
||
now = v.Now().UTC()
|
||
}
|
||
if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) {
|
||
return ValidationResult{}, fmt.Errorf("证书当前不在有效期内:%s 至 %s", leaf.NotBefore.UTC().Format(time.RFC3339), leaf.NotAfter.UTC().Format(time.RFC3339))
|
||
}
|
||
for _, name := range wanted {
|
||
probe := name
|
||
if strings.HasPrefix(probe, "*.") {
|
||
probe = "scert-wildcard-probe." + strings.TrimPrefix(probe, "*.")
|
||
}
|
||
if err := leaf.VerifyHostname(probe); err != nil {
|
||
return ValidationResult{}, fmt.Errorf("证书不覆盖域名 %s:%w", name, err)
|
||
}
|
||
}
|
||
if v.VerifyChain {
|
||
if err := verifyChain(certificates, v.Roots, now); err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
}
|
||
return validationResult(leaf, len(certificates)), nil
|
||
}
|
||
|
||
// RemoteValidator 校验远程 HTTPS 服务实际返回的证书。
|
||
type RemoteValidator struct {
|
||
Roots *x509.CertPool
|
||
Dialer *net.Dialer
|
||
Timeout time.Duration
|
||
Now func() time.Time
|
||
}
|
||
|
||
// ValidateHTTPS 连接目标 HTTPS 地址并校验证书链和域名。
|
||
func (v RemoteValidator) ValidateHTTPS(ctx context.Context, endpoint string, expectedDomains []string) (ValidationResult, error) {
|
||
target, err := normalizeHTTPSURL(endpoint)
|
||
if err != nil {
|
||
return ValidationResult{}, err
|
||
}
|
||
parsed, err := url.Parse(target)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("解析 HTTPS 地址失败:%w", err)
|
||
}
|
||
address := parsed.Host
|
||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
||
address = net.JoinHostPort(parsed.Hostname(), "443")
|
||
}
|
||
dialer := v.Dialer
|
||
if dialer == nil {
|
||
dialer = &net.Dialer{Timeout: v.timeout()}
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, v.timeout())
|
||
defer cancel()
|
||
tlsDialer := &tls.Dialer{
|
||
NetDialer: dialer,
|
||
Config: &tls.Config{
|
||
ServerName: parsed.Hostname(),
|
||
MinVersion: tls.VersionTLS12,
|
||
InsecureSkipVerify: true,
|
||
},
|
||
}
|
||
connection, err := tlsDialer.DialContext(ctx, "tcp", address)
|
||
if err != nil {
|
||
return ValidationResult{}, fmt.Errorf("remote HTTPS connection failed: %w", err)
|
||
}
|
||
tlsConnection, ok := connection.(*tls.Conn)
|
||
if !ok {
|
||
_ = connection.Close()
|
||
return ValidationResult{}, errors.New("remote HTTPS connection type is invalid")
|
||
}
|
||
defer tlsConnection.Close()
|
||
state := tlsConnection.ConnectionState()
|
||
if len(state.PeerCertificates) == 0 {
|
||
return ValidationResult{}, errors.New("远程 HTTPS 服务未返回证书")
|
||
}
|
||
certificates := make([]*x509.Certificate, len(state.PeerCertificates))
|
||
copy(certificates, state.PeerCertificates)
|
||
wanted := expectedDomains
|
||
if len(wanted) == 0 {
|
||
wanted = []string{parsed.Hostname()}
|
||
}
|
||
chainData := make([]byte, 0)
|
||
for _, certificate := range certificates {
|
||
chainData = append(chainData, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})...)
|
||
}
|
||
validator := LocalValidator{Roots: v.Roots, Now: v.Now, VerifyChain: true}
|
||
return validator.ValidatePEM(ctx, chainData, wanted)
|
||
}
|
||
|
||
func (v RemoteValidator) timeout() time.Duration {
|
||
if v.Timeout > 0 {
|
||
return v.Timeout
|
||
}
|
||
return 10 * time.Second
|
||
}
|
||
|
||
func normalizeHTTPSURL(value string) (string, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "", errors.New("HTTPS 地址不能为空")
|
||
}
|
||
if !strings.Contains(value, "://") {
|
||
value = "https://" + value
|
||
}
|
||
parsed, err := url.Parse(value)
|
||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
|
||
return "", errors.New("HTTPS 地址格式无效")
|
||
}
|
||
return parsed.String(), nil
|
||
}
|
||
|
||
func normalizeValidationNames(values []string) ([]string, error) {
|
||
seen := make(map[string]struct{}, len(values))
|
||
result := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
value = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if net.ParseIP(strings.TrimPrefix(value, "*.")) == nil && !validDomain(value) {
|
||
return nil, fmt.Errorf("证书校验域名无效:%s", value)
|
||
}
|
||
if _, exists := seen[value]; exists {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
result = append(result, value)
|
||
}
|
||
if len(result) == 0 {
|
||
return nil, errors.New("证书校验域名不能为空")
|
||
}
|
||
return result, nil
|
||
}
|
||
func parseCertificates(data []byte) ([]*x509.Certificate, error) {
|
||
remaining := data
|
||
certificates := make([]*x509.Certificate, 0, 2)
|
||
for len(remaining) > 0 {
|
||
block, rest := pem.Decode(remaining)
|
||
if block == nil {
|
||
break
|
||
}
|
||
remaining = rest
|
||
if block.Type != "CERTIFICATE" {
|
||
continue
|
||
}
|
||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析证书链失败:%w", err)
|
||
}
|
||
certificates = append(certificates, certificate)
|
||
}
|
||
if len(certificates) == 0 {
|
||
return nil, errors.New("未找到 PEM 证书链")
|
||
}
|
||
return certificates, nil
|
||
}
|
||
|
||
func verifyChain(certificates []*x509.Certificate, roots *x509.CertPool, now time.Time) error {
|
||
if roots == nil {
|
||
var err error
|
||
roots, err = x509.SystemCertPool()
|
||
if err != nil || roots == nil {
|
||
roots = x509.NewCertPool()
|
||
}
|
||
}
|
||
intermediates := x509.NewCertPool()
|
||
for _, certificate := range certificates[1:] {
|
||
if !certificate.IsCA {
|
||
continue
|
||
}
|
||
intermediates.AddCert(certificate)
|
||
}
|
||
_, err := certificates[0].Verify(x509.VerifyOptions{
|
||
Roots: roots,
|
||
Intermediates: intermediates,
|
||
DNSName: "",
|
||
CurrentTime: now,
|
||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("证书链校验失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validationResult(leaf *x509.Certificate, chainLength int) ValidationResult {
|
||
return ValidationResult{
|
||
Domains: append([]string(nil), leaf.DNSNames...),
|
||
SerialNumber: leaf.SerialNumber.Text(16),
|
||
FingerprintSHA256: fmt.Sprintf("%X", sha256.Sum256(leaf.Raw)),
|
||
NotBefore: leaf.NotBefore,
|
||
NotAfter: leaf.NotAfter,
|
||
ChainLength: chainLength,
|
||
}
|
||
}
|