mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Add opt-in support for proxy CA cert installation * fix: Code review fixes * fix: Code review fixes * fix: Code review fixes * fix: Code review fixes * fix: Code review fixes * docs: Add limitation for MacOS MDM script
446 lines
12 KiB
Go
446 lines
12 KiB
Go
package certmanager
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
const (
|
|
maxSystemCABundleBytes int64 = 10 * 1024 * 1024
|
|
certExpiryThreshold time.Duration = 1 * time.Hour
|
|
goosDarwin = "darwin"
|
|
goosLinux = "linux"
|
|
goosWindows = "windows"
|
|
)
|
|
|
|
// CACommonName is the subject CN of the PMG CA. It is the single source of truth
|
|
// for the CA's identity: GenerateCA stamps it onto the certificate and the
|
|
// truststore package matches on it to find/remove the cert in OS trust stores.
|
|
const CACommonName = "SafeDep PMG Proxy CA"
|
|
|
|
// certManager implements the CertificateManager interface
|
|
type certManager struct {
|
|
ca *Certificate
|
|
cache CertificateCache
|
|
config CertManagerConfig
|
|
group singleflight.Group
|
|
}
|
|
|
|
// NewCertificateManagerWithCA creates a new certificate manager with an existing CA certificate
|
|
func NewCertificateManagerWithCA(ca *Certificate, config CertManagerConfig) (CertificateManager, error) {
|
|
if ca == nil {
|
|
return nil, fmt.Errorf("CA certificate cannot be nil")
|
|
}
|
|
|
|
config.SetDefaults()
|
|
if err := config.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
if ca.X509Cert == nil || ca.PrivKey == nil {
|
|
parsedCA, err := parseCertificate(ca)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
|
}
|
|
|
|
ca = parsedCA
|
|
}
|
|
|
|
if ca.IsExpired(certExpiryThreshold) {
|
|
return nil, fmt.Errorf("CA certificate is expired")
|
|
}
|
|
|
|
return &certManager{
|
|
ca: ca,
|
|
cache: NewInMemoryCache(),
|
|
config: config,
|
|
}, nil
|
|
}
|
|
|
|
// GetCA returns the Certificate Authority certificate
|
|
func (cm *certManager) GetCA() (*Certificate, error) {
|
|
return cm.ca, nil
|
|
}
|
|
|
|
// GenerateCertForHost creates a certificate for the given hostname.
|
|
// Uses caching and singleflight to ensure only one goroutine generates
|
|
// a certificate for a given hostname at a time, preventing CPU starvation
|
|
// when many concurrent CONNECT requests arrive for the same host.
|
|
func (cm *certManager) GenerateCertForHost(hostname string) (*Certificate, error) {
|
|
if cached, found := cm.cache.Get(hostname); found {
|
|
if !cached.IsExpired(certExpiryThreshold) {
|
|
return cached, nil
|
|
}
|
|
}
|
|
|
|
result, err, _ := cm.group.Do(hostname, func() (interface{}, error) {
|
|
// Re-check cache: another goroutine in a previous singleflight
|
|
// group may have populated it while we were waiting.
|
|
if cached, found := cm.cache.Get(hostname); found {
|
|
if !cached.IsExpired(certExpiryThreshold) {
|
|
return cached, nil
|
|
}
|
|
}
|
|
|
|
cert, err := cm.generateHostCert(hostname)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate certificate for %s: %w", hostname, err)
|
|
}
|
|
|
|
cm.cache.Set(hostname, cert)
|
|
return cert, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result.(*Certificate), nil
|
|
}
|
|
|
|
// GetTLSConfig returns a tls.Config for the given hostname
|
|
func (cm *certManager) GetTLSConfig(hostname string) (*tls.Config, error) {
|
|
cert, err := cm.GenerateCertForHost(hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create tls.Certificate: %w", err)
|
|
}
|
|
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{tlsCert},
|
|
MinVersion: tls.VersionTLS12,
|
|
}, nil
|
|
}
|
|
|
|
func (cm *certManager) generateHostCert(hostname string) (*Certificate, error) {
|
|
privKey, err := rsa.GenerateKey(rand.Reader, cm.config.KeySize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
|
}
|
|
|
|
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
|
}
|
|
|
|
notBefore := time.Now()
|
|
notAfter := notBefore.Add(time.Duration(cm.config.HostCertValidityDays) * 24 * time.Hour)
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: serialNumber,
|
|
Subject: pkix.Name{
|
|
CommonName: hostname,
|
|
},
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
DNSNames: []string{hostname},
|
|
}
|
|
|
|
caPrivKey, ok := cm.ca.PrivKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("CA private key is not RSA")
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, cm.ca.X509Cert, &privKey.PublicKey, caPrivKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
|
}
|
|
|
|
x509Cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse generated certificate: %w", err)
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certDER,
|
|
})
|
|
|
|
privKeyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
|
})
|
|
|
|
return &Certificate{
|
|
Certificate: certPEM,
|
|
PrivateKey: privKeyPEM,
|
|
X509Cert: x509Cert,
|
|
PrivKey: privKey,
|
|
}, nil
|
|
}
|
|
|
|
// GenerateCA generates a new self-signed CA certificate using the given configuration
|
|
func GenerateCA(config CertManagerConfig) (*Certificate, error) {
|
|
config.SetDefaults()
|
|
if err := config.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
privKey, err := rsa.GenerateKey(rand.Reader, config.KeySize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate CA private key: %w", err)
|
|
}
|
|
|
|
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
|
}
|
|
|
|
notBefore := time.Now()
|
|
notAfter := notBefore.Add(time.Duration(config.CAValidityDays) * 24 * time.Hour)
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: serialNumber,
|
|
Subject: pkix.Name{
|
|
CommonName: CACommonName,
|
|
Organization: []string{"SafeDep PMG"},
|
|
},
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create CA certificate: %w", err)
|
|
}
|
|
|
|
x509Cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse generated CA certificate: %w", err)
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certDER,
|
|
})
|
|
|
|
privKeyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
|
})
|
|
|
|
return &Certificate{
|
|
Certificate: certPEM,
|
|
PrivateKey: privKeyPEM,
|
|
X509Cert: x509Cert,
|
|
PrivKey: privKey,
|
|
}, nil
|
|
}
|
|
|
|
func parseCertificate(cert *Certificate) (*Certificate, error) {
|
|
certBlock, _ := pem.Decode(cert.Certificate)
|
|
if certBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode PEM certificate")
|
|
}
|
|
|
|
x509Cert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse X509 certificate: %w", err)
|
|
}
|
|
|
|
keyBlock, _ := pem.Decode(cert.PrivateKey)
|
|
if keyBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode PEM private key")
|
|
}
|
|
|
|
privKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse RSA private key: %w", err)
|
|
}
|
|
|
|
return &Certificate{
|
|
Certificate: cert.Certificate,
|
|
PrivateKey: cert.PrivateKey,
|
|
X509Cert: x509Cert,
|
|
PrivKey: privKey,
|
|
}, nil
|
|
}
|
|
|
|
// ParseTLSCertificate converts a Certificate to a tls.Certificate
|
|
// This is useful for integrating with libraries that expect tls.Certificate
|
|
func ParseTLSCertificate(cert *Certificate) (tls.Certificate, error) {
|
|
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("failed to create X509 key pair: %w", err)
|
|
}
|
|
|
|
// Populate Leaf field if we have the parsed X.509 certificate
|
|
// This is important for libraries (like goproxy) that need to inspect the certificate
|
|
if cert.X509Cert != nil {
|
|
tlsCert.Leaf = cert.X509Cert
|
|
}
|
|
|
|
return tlsCert, nil
|
|
}
|
|
|
|
// MergeWithSystemCA appends the system CA bundle to certPEM so env-var trust
|
|
// injection (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, ...) covers both the PMG CA and
|
|
// the real world. It is best-effort: any problem locating, sizing, or reading the
|
|
// system bundle results in certPEM being returned unchanged, so proxy startup is
|
|
// never blocked by an optional enhancement.
|
|
func MergeWithSystemCA(certPEM []byte) []byte {
|
|
systemBundlePath := firstReadablePath(systemCABundleCandidates()...)
|
|
if systemBundlePath == "" {
|
|
log.Warnf("Skipping system CA bundle merge: No system CA bundle file found")
|
|
return certPEM
|
|
}
|
|
|
|
info, err := os.Stat(systemBundlePath)
|
|
if err != nil {
|
|
log.Errorf("Skipping system CA bundle merge: failed to stat %s: %v", systemBundlePath, err)
|
|
return certPEM
|
|
}
|
|
|
|
if info.Size() > maxSystemCABundleBytes {
|
|
log.Errorf("Skipping system CA bundle merge: %s is too large (%d bytes > %d bytes)",
|
|
systemBundlePath, info.Size(), maxSystemCABundleBytes)
|
|
return certPEM
|
|
}
|
|
|
|
systemBundle, err := os.ReadFile(systemBundlePath)
|
|
if err != nil {
|
|
log.Errorf("Skipping system CA bundle merge: failed to read %s: %v", systemBundlePath, err)
|
|
return certPEM
|
|
}
|
|
|
|
const extra = int64(2)
|
|
totalCap := int64(len(certPEM)) + int64(len(systemBundle)) + extra
|
|
if totalCap > maxSystemCABundleBytes {
|
|
log.Errorf("Skipping system CA bundle merge: merged CA would be too large (%d bytes > %d bytes)",
|
|
totalCap, maxSystemCABundleBytes)
|
|
return certPEM
|
|
}
|
|
|
|
merged := make([]byte, 0, int(totalCap))
|
|
merged = append(merged, certPEM...)
|
|
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
|
|
merged = append(merged, '\n')
|
|
}
|
|
merged = append(merged, systemBundle...)
|
|
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
|
|
merged = append(merged, '\n')
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
// GenerateCAWithSystemCA generates a self-signed CA and merges the system CA
|
|
// bundle into the certificate bytes (for env-var trust injection). Signing
|
|
// always uses the pure CA via X509Cert/PrivKey.
|
|
func GenerateCAWithSystemCA(config CertManagerConfig) (*Certificate, error) {
|
|
caCert, err := GenerateCA(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate CA: %w", err)
|
|
}
|
|
|
|
return &Certificate{
|
|
Certificate: MergeWithSystemCA(caCert.Certificate),
|
|
PrivateKey: caCert.PrivateKey,
|
|
X509Cert: caCert.X509Cert,
|
|
PrivKey: caCert.PrivKey,
|
|
}, nil
|
|
}
|
|
|
|
func firstReadablePath(paths ...string) string {
|
|
for _, path := range paths {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.IsDir() {
|
|
continue
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
_ = f.Close()
|
|
|
|
return path
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func systemCABundleCandidates() []string {
|
|
return systemCABundleCandidatesForOS(runtime.GOOS)
|
|
}
|
|
|
|
func systemCABundleCandidatesForOS(goos string) []string {
|
|
var candidates []string
|
|
appendIfSet := func(key string) {
|
|
if value := os.Getenv(key); value != "" {
|
|
candidates = append(candidates, value)
|
|
}
|
|
}
|
|
|
|
appendIfSet("SSL_CERT_FILE")
|
|
appendIfSet("CURL_CA_BUNDLE")
|
|
|
|
switch goos {
|
|
case goosDarwin:
|
|
candidates = append(candidates,
|
|
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
"/usr/local/etc/openssl@3/cert.pem",
|
|
"/etc/ssl/cert.pem",
|
|
)
|
|
case goosLinux:
|
|
candidates = append(candidates,
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
|
"/etc/ssl/ca-bundle.pem",
|
|
"/etc/ssl/cert.pem",
|
|
)
|
|
case goosWindows:
|
|
programFiles := os.Getenv("ProgramFiles")
|
|
programFilesX86 := os.Getenv("ProgramFiles(x86)")
|
|
systemRoot := os.Getenv("SystemRoot")
|
|
|
|
if programFiles != "" {
|
|
candidates = append(candidates,
|
|
filepath.Join(programFiles, "Git", "mingw64", "ssl", "certs", "ca-bundle.crt"),
|
|
filepath.Join(programFiles, "Git", "usr", "ssl", "certs", "ca-bundle.crt"),
|
|
)
|
|
}
|
|
if programFilesX86 != "" {
|
|
candidates = append(candidates,
|
|
filepath.Join(programFilesX86, "Git", "mingw32", "ssl", "certs", "ca-bundle.crt"),
|
|
)
|
|
}
|
|
if systemRoot != "" {
|
|
candidates = append(candidates,
|
|
filepath.Join(systemRoot, "System32", "curl-ca-bundle.crt"),
|
|
)
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|