Scert/backend/internal/certificate/validation.go

273 lines
8.2 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 (
"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)
}
if _, err := tls.X509KeyPair(chainData, privateKeyData); err != nil {
return ValidationResult{}, fmt.Errorf("证书与私钥不匹配:%w", err)
}
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,
}
}