~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
241 lines · 7.5 KB Raw History
package eso

import (
	"bytes"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"math/big"
	"net"
	"os"
	"path/filepath"
	"time"
)

const (
	webhookCACommonName     = "socialweb-external-secrets-webhook-ca"
	webhookServerCommonName = "socialweb-external-secrets-webhook"
	webhookCAValidity       = 10 * 365 * 24 * time.Hour
	webhookLeafValidity     = 2 * 365 * 24 * time.Hour
	webhookTLSFileMode      = 0o600
)

type WebhookTLSPaths struct {
	CertDir string

	CAKey string
}

func (p WebhookTLSPaths) caCert() string { return filepath.Join(p.CertDir, WebhookCAName) }

func (p WebhookTLSPaths) leafCert() string { return filepath.Join(p.CertDir, WebhookCertName) }

func (p WebhookTLSPaths) leafKey() string { return filepath.Join(p.CertDir, WebhookKeyName) }

func EnsureWebhookTLS(p WebhookTLSPaths, now time.Time) ([]byte, error) {
	if p.CertDir == "" || p.CAKey == "" {
		return nil, errors.New("eso: the webhook's TLS needs the cert dir the webhook reads and a " +
			"path outside it for the CA key")
	}
	if filepath.Clean(filepath.Dir(p.CAKey)) == filepath.Clean(p.CertDir) {
		return nil, fmt.Errorf("eso: the webhook CA key %s would sit in the cert dir the webhook "+
			"process reads, which hands the process the key that signs its own trust", p.CAKey)
	}
	for _, dir := range []string{p.CertDir, filepath.Dir(p.CAKey)} {
		if err := os.MkdirAll(dir, 0o700); err != nil {
			return nil, fmt.Errorf("eso: creating %s for the webhook's TLS: %w", dir, err)
		}
	}
	caPEM, caCert, caKey, ok := loadWebhookCA(p, now)
	if !ok {
		var err error
		caPEM, caCert, caKey, err = newWebhookCA(now)
		if err != nil {
			return nil, err
		}
		keyDER, err := x509.MarshalPKCS8PrivateKey(caKey)
		if err != nil {
			return nil, fmt.Errorf("eso: marshalling the webhook CA key: %w", err)
		}
		if err := writeFile(p.CAKey, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})); err != nil {
			return nil, err
		}
		if err := writeFile(p.caCert(), caPEM); err != nil {
			return nil, err
		}
	}
	if leafUsable(p, caCert, now) {
		return caPEM, nil
	}
	certPEM, keyPEM, err := signWebhookLeaf(now, caKey, caCert)
	if err != nil {
		return nil, err
	}
	if err := writeFile(p.leafKey(), keyPEM); err != nil {
		return nil, err
	}
	if err := writeFile(p.leafCert(), certPEM); err != nil {
		return nil, err
	}
	return caPEM, nil
}

func loadWebhookCA(p WebhookTLSPaths, now time.Time) ([]byte, *x509.Certificate, *ecdsa.PrivateKey, bool) {
	caPEM, err := os.ReadFile(p.caCert())
	if err != nil {
		return nil, nil, nil, false
	}
	block, _ := pem.Decode(caPEM)
	if block == nil {
		return nil, nil, nil, false
	}
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil || !cert.IsCA || cert.NotAfter.Before(now.Add(webhookLeafValidity)) {
		return nil, nil, nil, false
	}
	keyPEM, err := os.ReadFile(p.CAKey)
	if err != nil {
		return nil, nil, nil, false
	}
	keyBlock, _ := pem.Decode(keyPEM)
	if keyBlock == nil {
		return nil, nil, nil, false
	}
	parsed, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
	if err != nil {
		return nil, nil, nil, false
	}
	key, ok := parsed.(*ecdsa.PrivateKey)
	if !ok || !key.PublicKey.Equal(cert.PublicKey) {
		return nil, nil, nil, false
	}
	return caPEM, cert, key, true
}

func leafUsable(p WebhookTLSPaths, ca *x509.Certificate, now time.Time) bool {
	pair, err := tls.LoadX509KeyPair(p.leafCert(), p.leafKey())
	if err != nil || len(pair.Certificate) == 0 {
		return false
	}
	leaf, err := x509.ParseCertificate(pair.Certificate[0])
	if err != nil {
		return false
	}
	if leaf.NotAfter.Sub(now) < WebhookRenewBefore {
		return false
	}
	pool := x509.NewCertPool()
	pool.AddCert(ca)
	for _, name := range []string{"127.0.0.1", WebhookDNSName} {
		if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: now,
			KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil {
			return false
		}
	}
	return true
}

func serial() (*big.Int, error) {
	n, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		return nil, fmt.Errorf("eso: drawing a certificate serial: %w", err)
	}
	return n.Add(n, big.NewInt(1)), nil
}

func newWebhookCA(now time.Time) ([]byte, *x509.Certificate, *ecdsa.PrivateKey, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("eso: generating the webhook CA key: %w", err)
	}
	sn, err := serial()
	if err != nil {
		return nil, nil, nil, err
	}
	template := &x509.Certificate{
		SerialNumber:          sn,
		Subject:               pkix.Name{CommonName: webhookCACommonName},
		NotBefore:             now.Add(-time.Hour),
		NotAfter:              now.Add(webhookCAValidity),
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
		BasicConstraintsValid: true,
		IsCA:                  true,
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("eso: signing the webhook CA: %w", err)
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("eso: parsing the webhook CA: %w", err)
	}
	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), cert, key, nil
}

func signWebhookLeaf(now time.Time, caKey *ecdsa.PrivateKey, ca *x509.Certificate) ([]byte, []byte, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, fmt.Errorf("eso: generating the webhook serving key: %w", err)
	}
	sn, err := serial()
	if err != nil {
		return nil, nil, err
	}
	notAfter := now.Add(webhookLeafValidity)
	if notAfter.After(ca.NotAfter) {
		notAfter = ca.NotAfter
	}
	template := &x509.Certificate{
		SerialNumber:          sn,
		Subject:               pkix.Name{CommonName: webhookServerCommonName},
		NotBefore:             now.Add(-time.Hour),
		NotAfter:              notAfter,
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
		DNSNames:              []string{WebhookDNSName},
		IPAddresses:           []net.IP{net.IPv4(127, 0, 0, 1)},
	}
	der, err := x509.CreateCertificate(rand.Reader, template, ca, key.Public(), caKey)
	if err != nil {
		return nil, nil, fmt.Errorf("eso: signing the webhook serving certificate: %w", err)
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, nil, fmt.Errorf("eso: marshalling the webhook serving key: %w", err)
	}
	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), nil
}

func writeFile(path string, data []byte) error {
	if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, data) {
		return os.Chmod(path, webhookTLSFileMode)
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".")
	if err != nil {
		return fmt.Errorf("eso: a temporary file beside %s: %w", path, err)
	}
	defer func() { _ = os.Remove(tmp.Name()) }()
	if err := tmp.Chmod(webhookTLSFileMode); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("eso: the mode of %s: %w", tmp.Name(), err)
	}
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("eso: writing %s: %w", tmp.Name(), err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("eso: closing %s: %w", tmp.Name(), err)
	}
	if err := os.Rename(tmp.Name(), path); err != nil {
		return fmt.Errorf("eso: renaming %s over %s: %w", tmp.Name(), path, err)
	}
	return nil
}