~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
374 lines · 10.6 KB Raw History
package vaultcert

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

	"github.com/johnandersen777/socialweb-computer-kcp/internal/clusterstack"
)

const (
	vaultTLSCACommonName     = "socialweb-vault-tls-ca"
	vaultTLSServerCommonName = "socialweb-vault"
	vaultTLSValidity         = 2 * 365 * 24 * time.Hour
	vaultTLSRenewBefore      = 30 * 24 * time.Hour
	vaultTLSFileMode         = 0o600
)

type VaultTLS struct {
	CertFile string

	KeyFile string

	CAPath string

	CAKeyPath string

	CAPEM []byte
}

var ErrNoVaultTLSHost = errors.New(
	"vaultcert: the vault's TLS certificate names no host, so a client has no name to verify and " +
		"the listener cannot be dialed with server verification on. The vault's tenant-facing " +
		"listener is the cluster's gateway address, which is the one address a guest can dial")

func EnsureVaultTLSCertificate(dataDir string, hosts ...string) (VaultTLS, error) {
	if len(hosts) == 0 {
		return VaultTLS{}, fmt.Errorf("%w (none were given)", ErrNoVaultTLSHost)
	}
	dns, ips, err := splitHosts(hosts)
	if err != nil {
		return VaultTLS{}, err
	}
	out := VaultTLS{
		CertFile:  clusterstack.OpenBaoTLSCertPath(dataDir),
		KeyFile:   clusterstack.OpenBaoTLSKeyPath(dataDir),
		CAPath:    clusterstack.OpenBaoTLSCAPath(dataDir),
		CAKeyPath: clusterstack.OpenBaoTLSCAKeyPath(dataDir),
	}
	if reuse, ok := reuseVaultTLS(out, dns, ips); ok {
		return reuse, nil
	}
	caPEM, caCert, ok := vaultTLSCAOnDisk(out)
	if !ok {
		return generateVaultTLS(out, dns, ips)
	}
	caKey, ok := vaultTLSCAKeyOnDisk(caCert, out)
	if ok {
		existingDNS, existingIPs := existingLeafHosts(out.CertFile, out.KeyFile)
		dns, ips = unionHosts(existingDNS, existingIPs, dns, ips)
		return signVaultTLS(out, time.Now(), caKey, caCert, caPEM, dns, ips)
	}
	if keep, _, ok := usableVaultTLSPair(out); ok {
		return keep, nil
	}
	return generateVaultTLS(out, dns, ips)
}

func vaultTLSCAOnDisk(v VaultTLS) ([]byte, *x509.Certificate, bool) {
	caPEM, err := os.ReadFile(v.CAPath)
	if err != nil {
		return nil, nil, false
	}
	block, _ := pem.Decode(caPEM)
	if block == nil || block.Type != "CERTIFICATE" {
		return nil, nil, false
	}
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil || !cert.IsCA {
		return nil, nil, false
	}
	if time.Until(cert.NotAfter) < vaultTLSRenewBefore {
		return nil, nil, false
	}
	return caPEM, cert, true
}

func vaultTLSCAKeyOnDisk(caCert *x509.Certificate, v VaultTLS) (*ecdsa.PrivateKey, bool) {
	keyPEM, err := os.ReadFile(v.CAKeyPath)
	if err != nil {
		return nil, false
	}
	block, _ := pem.Decode(keyPEM)
	if block == nil {
		return nil, false
	}
	parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, false
	}
	key, ok := parsed.(*ecdsa.PrivateKey)
	if !ok {
		return nil, false
	}
	want, err := x509.MarshalPKIXPublicKey(caCert.PublicKey)
	if err != nil {
		return nil, false
	}
	got, err := x509.MarshalPKIXPublicKey(key.Public())
	if err != nil || !bytes.Equal(want, got) {
		return nil, false
	}
	return key, true
}

func usableVaultTLSPair(v VaultTLS) (VaultTLS, *x509.Certificate, bool) {
	caPEM, err := os.ReadFile(v.CAPath)
	if err != nil {
		return VaultTLS{}, nil, false
	}
	pair, err := tls.LoadX509KeyPair(v.CertFile, v.KeyFile)
	if err != nil {
		return VaultTLS{}, nil, false
	}
	leaf, err := x509.ParseCertificate(pair.Certificate[0])
	if err != nil {
		return VaultTLS{}, nil, false
	}
	if time.Until(leaf.NotAfter) < vaultTLSRenewBefore {
		return VaultTLS{}, nil, false
	}
	pool := x509.NewCertPool()
	if !pool.AppendCertsFromPEM(caPEM) {
		return VaultTLS{}, nil, false
	}
	if _, err := leaf.Verify(x509.VerifyOptions{
		Roots:     pool,
		KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}); err != nil {
		return VaultTLS{}, nil, false
	}
	v.CAPEM = caPEM
	return v, leaf, true
}

func existingLeafHosts(certFile, keyFile string) ([]string, []netip.Addr) {
	pair, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, nil
	}
	leaf, err := x509.ParseCertificate(pair.Certificate[0])
	if err != nil {
		return nil, nil
	}
	var ips []netip.Addr
	for _, raw := range leaf.IPAddresses {
		if addr, err := netip.ParseAddr(raw.String()); err == nil {
			ips = append(ips, addr)
		}
	}
	return leaf.DNSNames, ips
}

func unionHosts(dns []string, ips []netip.Addr, moreDNS []string, moreIPs []netip.Addr) ([]string, []netip.Addr) {
	seenDNS := make(map[string]bool, len(dns))
	for _, d := range dns {
		seenDNS[d] = true
	}
	for _, d := range moreDNS {
		if !seenDNS[d] {
			seenDNS[d] = true
			dns = append(dns, d)
		}
	}
	seenIP := make(map[netip.Addr]bool, len(ips))
	for _, ip := range ips {
		seenIP[ip.Unmap()] = true
	}
	for _, ip := range moreIPs {
		if !seenIP[ip.Unmap()] {
			seenIP[ip.Unmap()] = true
			ips = append(ips, ip)
		}
	}
	return dns, ips
}

func splitHosts(hosts []string) ([]string, []netip.Addr, error) {
	var dns []string
	var ips []netip.Addr
	for _, h := range hosts {
		if h == "" {
			return nil, nil, fmt.Errorf("%w (one of them is empty)", ErrNoVaultTLSHost)
		}
		if addr, err := netip.ParseAddr(h); err == nil {
			ips = append(ips, addr)
			continue
		}
		dns = append(dns, h)
	}
	return dns, ips, nil
}

func reuseVaultTLS(v VaultTLS, dns []string, ips []netip.Addr) (VaultTLS, bool) {
	reuse, leaf, ok := usableVaultTLSPair(v)
	if !ok || !coversHosts(leaf, dns, ips) {
		return VaultTLS{}, false
	}
	return reuse, true
}

func coversHosts(leaf *x509.Certificate, dns []string, ips []netip.Addr) bool {
	haveDNS := map[string]bool{}
	for _, d := range leaf.DNSNames {
		haveDNS[d] = true
	}
	haveIP := map[netip.Addr]bool{}
	for _, raw := range leaf.IPAddresses {
		if addr, err := netip.ParseAddr(raw.String()); err == nil {
			haveIP[addr.Unmap()] = true
		}
	}
	for _, d := range dns {
		if !haveDNS[d] {
			return false
		}
	}
	for _, ip := range ips {
		if !haveIP[ip.Unmap()] {
			return false
		}
	}
	return true
}

func generateVaultTLS(v VaultTLS, dns []string, ips []netip.Addr) (VaultTLS, error) {
	now := time.Now()
	caKey, caCert, caDER, err := newVaultTLSCA(now)
	if err != nil {
		return VaultTLS{}, err
	}
	caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
	caKeyDER, err := x509.MarshalPKCS8PrivateKey(caKey)
	if err != nil {
		return VaultTLS{}, fmt.Errorf("vaultcert: marshalling the vault TLS CA key: %w", err)
	}
	caKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: caKeyDER})

	out, err := signVaultTLS(v, now, caKey, caCert, caPEM, dns, ips)
	if err != nil {
		return VaultTLS{}, err
	}
	for _, f := range []struct {
		path string
		data []byte
	}{
		{v.CAPath, caPEM},
		{v.CAKeyPath, caKeyPEM},
	} {
		if err := os.WriteFile(f.path, f.data, vaultTLSFileMode); err != nil {
			return VaultTLS{}, fmt.Errorf("vaultcert: writing %s: %w", f.path, err)
		}
	}
	return out, nil
}

func signVaultTLS(v VaultTLS, now time.Time, caKey *ecdsa.PrivateKey, caCert *x509.Certificate, caPEM []byte, dns []string, ips []netip.Addr) (VaultTLS, error) {
	serverPEM, keyPEM, err := signLeaf(now, caKey, caCert, vaultTLSServerCommonName,
		x509.ExtKeyUsageServerAuth, dns, ips)
	if err != nil {
		return VaultTLS{}, err
	}
	chainPEM := append(append([]byte{}, serverPEM...), caPEM...)

	dir := filepath.Dir(v.CertFile)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return VaultTLS{}, fmt.Errorf("vaultcert: creating the vault's TLS directory %s: %w", dir, err)
	}
	for _, f := range []struct {
		path string
		data []byte
	}{
		{v.CertFile, chainPEM},
		{v.KeyFile, keyPEM},
	} {
		if err := os.WriteFile(f.path, f.data, vaultTLSFileMode); err != nil {
			return VaultTLS{}, fmt.Errorf("vaultcert: writing %s: %w", f.path, err)
		}
	}
	v.CAPEM = caPEM
	return v, nil
}

func signLeaf(now time.Time, caKey *ecdsa.PrivateKey, caCert *x509.Certificate, commonName string, eku x509.ExtKeyUsage, dns []string, ips []netip.Addr) ([]byte, []byte, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, fmt.Errorf("vaultcert: generating the TLS key: %w", err)
	}
	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		return nil, nil, fmt.Errorf("vaultcert: drawing the TLS serial: %w", err)
	}
	serial.Add(serial, big.NewInt(1))
	template := &x509.Certificate{
		SerialNumber:          serial,
		Subject:               pkix.Name{CommonName: commonName},
		NotBefore:             now.Add(-time.Hour),
		NotAfter:              now.Add(vaultTLSValidity),
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:           []x509.ExtKeyUsage{eku},
		BasicConstraintsValid: true,
		DNSNames:              dns,
	}
	for _, ip := range ips {
		template.IPAddresses = append(template.IPAddresses, ip.AsSlice())
	}
	der, err := x509.CreateCertificate(rand.Reader, template, caCert, key.Public(), caKey)
	if err != nil {
		return nil, nil, fmt.Errorf("vaultcert: signing the TLS certificate: %w", err)
	}
	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, nil, fmt.Errorf("vaultcert: marshalling the TLS key: %w", err)
	}
	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
	return certPEM, keyPEM, nil
}

func newVaultTLSCA(now time.Time) (*ecdsa.PrivateKey, *x509.Certificate, []byte, error) {
	return newNamedCA(now, vaultTLSCACommonName)
}

func newNamedCA(now time.Time, commonName string) (*ecdsa.PrivateKey, *x509.Certificate, []byte, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("vaultcert: generating the vault TLS CA key: %w", err)
	}
	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		return nil, nil, nil, fmt.Errorf("vaultcert: drawing the vault TLS CA serial: %w", err)
	}
	serial.Add(serial, big.NewInt(1))
	template := &x509.Certificate{
		SerialNumber:          serial,
		Subject:               pkix.Name{CommonName: commonName},
		NotBefore:             now.Add(-time.Hour),
		NotAfter:              now.Add(vaultTLSValidity),
		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("vaultcert: signing the vault TLS CA: %w", err)
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("vaultcert: parsing the vault TLS CA: %w", err)
	}
	return key, cert, der, nil
}