~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
320 lines · 11.2 KB Raw History
package clientcert

import (
	"cmp"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"log/slog"
	"math/big"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/clusterprovider"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/clusterhome"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gateway"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcpclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kubeconfig"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
)

var ErrVaultUnavailable = gateway.ErrIssuerUnavailable

const (
	issueTimeout  = 10 * time.Second
	revokeTimeout = 30 * time.Second
	crlTimeout    = 5 * time.Second
	lifetimeSlack = 5 * time.Minute
)

const noExpiryNotAfter = "9999-12-31T23:59:59Z"

type Recorder interface {
	RecordCertificate(ctx context.Context, rec kcpclient.CertificateRecord) error
	MarkCertificateRevoked(ctx context.Context, did, cluster, serial string) error
}

type Issuer struct {
	StateDir string
	CAFile   string
	Logins   *openbao.Logins
	Log      *slog.Logger
	Recorder Recorder
}

func DataDir(stateDir, did, cluster string) (string, error) {
	key, err := clusterhome.Key(clusterhome.HomePath(did, cluster))
	if err != nil {
		return "", err
	}
	return clusterprovider.ClusterDataDir(stateDir, key)
}

func (i *Issuer) log() *slog.Logger {
	if i.Log != nil {
		return i.Log
	}
	return slog.Default()
}

func (i *Issuer) Grant(ctx context.Context, in kcpclient.GrantInput) (string, error) {
	if in.Caller == "" || in.Cluster == "" || in.Server == "" {
		return "", errors.New("clientcert: a caller, a cluster and a server are required")
	}
	ttl := in.TTL
	ttl = cmp.Or(ttl, gateway.DefaultClientCertTTL)
	if in.NoExpiry {
		if in.TTL != 0 {
			return "", errors.New("clientcert: a lifetime and no expiry are exclusive")
		}
	} else if ttl < gateway.MinClientCertTTL || ttl > gateway.MaxClientCertTTL {
		return "", errors.New("clientcert: the lifetime is outside the allowed window")
	}
	dir, err := DataDir(i.StateDir, string(in.Caller), in.Cluster)
	if err != nil {
		return "", err
	}
	if certPEM, keyPEM, leaf, ok, err := signFromClientCA(dir, string(in.Caller), ttl, in.NoExpiry); err != nil {
		return "", err
	} else if ok {
		return i.recordAndBuild(ctx, in, leaf, leaf.SerialNumber.Text(16), certPEM, keyPEM, nil)
	}
	client, _, err := clusterprovider.GatewayVaultClient(ctx, i.Logins, dir)
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrVaultUnavailable, err)
	}
	cctx, cancel := context.WithTimeout(ctx, issueTimeout)
	defer cancel()
	role, req := clusterprovider.VaultPKIRoleKubeconfig, openbao.CertRequest{
		CommonName:        string(in.Caller),
		TTL:               strconv.FormatInt(int64(ttl/time.Second), 10) + "s",
		ExcludeCNFromSANs: true,
	}
	if in.NoExpiry {
		role, req.TTL, req.NotAfter = clusterprovider.VaultPKIRoleKubeconfigNoExpiry, "", noExpiryNotAfter
	}
	if err := clusterprovider.ImportClientCA(cctx, dir, client); err != nil {
		return "", fmt.Errorf("%w: %v", ErrVaultUnavailable, err)
	}
	cert, err := client.IssueCert(cctx, clusterprovider.VaultPKIPath, role, req)
	if err != nil {
		return "", fmt.Errorf("%w: issue: %v", ErrVaultUnavailable, err)
	}
	leaf, err := parseCertificate(cert.Certificate)
	if err != nil {
		return "", err
	}
	switch {
	case leaf.Subject.CommonName != string(in.Caller):
		return "", errors.New("clientcert: the issued certificate has the wrong common name")
	case !slices.Equal(leaf.Subject.Organization, []string{clusterhome.OwnersGroup}):
		return "", errors.New("clientcert: the issued certificate has the wrong organization")
	case !in.NoExpiry && leaf.NotAfter.Sub(leaf.NotBefore) > gateway.MaxClientCertTTL+lifetimeSlack:
		return "", errors.New("clientcert: the issued certificate lives too long")
	case in.NoExpiry != slices.Contains(leaf.Subject.OrganizationalUnit, clusterprovider.NoExpiryOrganizationalUnit):
		return "", errors.New("clientcert: the issued certificate has the wrong organizational unit")
	case !slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth):
		return "", errors.New("clientcert: the issued certificate is not a client certificate")
	case cert.PrivateKey == "":
		return "", errors.New("clientcert: the issuer returned no private key")
	}
	return i.recordAndBuild(ctx, in, leaf, cert.Serial, cert.Certificate, cert.PrivateKey,
		func() { i.revokeSerial(ctx, client, cert.Serial) })
}

func (i *Issuer) recordAndBuild(ctx context.Context, in kcpclient.GrantInput, leaf *x509.Certificate, serial, certPEM, keyPEM string, revoke func()) (string, error) {
	if i.Recorder == nil {
		if revoke != nil {
			revoke()
		}
		return "", fmt.Errorf("%w: no recorder for certificates", ErrVaultUnavailable)
	}
	rec := kcpclient.CertificateRecord{DID: string(in.Caller), Cluster: in.Cluster, Serial: serial,
		NotAfter: leaf.NotAfter, NoExpiry: in.NoExpiry}
	if err := i.Recorder.RecordCertificate(ctx, rec); err != nil {
		if revoke != nil {
			revoke()
		}
		return "", fmt.Errorf("%w: record: %v", ErrVaultUnavailable, err)
	}
	var caPEM []byte
	var err error
	if i.CAFile != "" {
		if caPEM, err = os.ReadFile(i.CAFile); err != nil {
			return "", fmt.Errorf("clientcert: reading the CA file: %w", err)
		}
	}
	out, err := kubeconfig.BuildClientCert(kubeconfig.ClientCertInput{ClusterName: in.Cluster, Server: in.Server,
		CACertPEM: caPEM, CertPEM: []byte(certPEM), KeyPEM: []byte(keyPEM)})
	if err != nil {
		return "", err
	}
	msg := "clientcert: issued a kubeconfig certificate"
	if in.NoExpiry {
		msg = "clientcert: kubeconfig no-expiry certificate issued"
	}
	i.log().Info(msg, "did", string(in.Caller), "cluster", in.Cluster, "serial", serial,
		"notAfter", leaf.NotAfter.UTC().Format(time.RFC3339))
	return out, nil
}

func (i *Issuer) revokeSerial(ctx context.Context, client *openbao.Client, serial string) {
	if err := client.Write(ctx, "pki/revoke", map[string]any{"serial_number": serial}); err != nil {
		i.log().Warn("clientcert: the revoke of an unrecorded certificate failed", "serial", serial, "err", err.Error())
	}
}

func signFromClientCA(dir, did string, ttl time.Duration, noExpiry bool) (string, string, *x509.Certificate, bool, error) {
	tlsDir := filepath.Join(dir, "server", "tls")
	certPEM, err := os.ReadFile(filepath.Join(tlsDir, "client-ca.crt"))
	if err != nil {
		return "", "", nil, false, nil
	}
	keyPEM, err := os.ReadFile(filepath.Join(tlsDir, "client-ca.key"))
	if err != nil {
		return "", "", nil, false, nil
	}
	caBlock, _ := pem.Decode(certPEM)
	if caBlock == nil {
		return "", "", nil, false, fmt.Errorf("clientcert: %s holds no certificate", filepath.Join(tlsDir, "client-ca.crt"))
	}
	caCert, err := x509.ParseCertificate(caBlock.Bytes)
	if err != nil {
		return "", "", nil, false, err
	}
	keyBlock, _ := pem.Decode(keyPEM)
	if keyBlock == nil {
		return "", "", nil, false, fmt.Errorf("clientcert: %s holds no key", filepath.Join(tlsDir, "client-ca.key"))
	}
	caKey, err := x509.ParseECPrivateKey(keyBlock.Bytes)
	if err != nil {
		parsed, parseErr := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
		if parseErr != nil {
			return "", "", nil, false, err
		}
		var ok bool
		if caKey, ok = parsed.(*ecdsa.PrivateKey); !ok {
			return "", "", nil, false, errors.New("clientcert: the client CA key is not ECDSA")
		}
	}
	if !caCert.PublicKey.(*ecdsa.PublicKey).Equal(&caKey.PublicKey) {
		return "", "", nil, false, errors.New("clientcert: the client CA key is not its certificate's key")
	}
	leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return "", "", nil, false, err
	}
	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
	if err != nil {
		return "", "", nil, false, err
	}
	now := time.Now()
	notAfter := now.Add(ttl)
	ou := []string(nil)
	if noExpiry {
		notAfter = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
		ou = []string{clusterprovider.NoExpiryOrganizationalUnit}
	}
	tmpl := &x509.Certificate{SerialNumber: serial,
		Subject:   pkix.Name{CommonName: did, Organization: []string{clusterhome.OwnersGroup}, OrganizationalUnit: ou},
		NotBefore: now.Add(-time.Minute), NotAfter: notAfter,
		KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
		BasicConstraintsValid: true}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &leafKey.PublicKey, caKey)
	if err != nil {
		return "", "", nil, false, err
	}
	leaf, err := x509.ParseCertificate(der)
	if err != nil {
		return "", "", nil, false, err
	}
	kder, err := x509.MarshalECPrivateKey(leafKey)
	if err != nil {
		return "", "", nil, false, err
	}
	return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
		string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder})), leaf, true, nil
}

func parseCertificate(pemText string) (*x509.Certificate, error) {
	block, _ := pem.Decode([]byte(pemText))
	if block == nil {
		return nil, errors.New("clientcert: the issuer returned no certificate")
	}
	return x509.ParseCertificate(block.Bytes)
}

func (i *Issuer) Revoke(ctx context.Context, did, cluster string) (int, error) {
	dir, err := DataDir(i.StateDir, did, cluster)
	if err != nil {
		return 0, err
	}
	ctx, cancel := context.WithTimeout(ctx, revokeTimeout)
	defer cancel()
	client, _, err := clusterprovider.GatewayVaultClient(ctx, i.Logins, dir)
	if err != nil {
		return 0, fmt.Errorf("%w: %v", ErrVaultUnavailable, err)
	}
	serials, err := client.List(ctx, "pki/certs")
	if err != nil {
		return 0, fmt.Errorf("clientcert: listing certificates: %w", err)
	}
	count := 0
	now := time.Now()
	for _, serial := range serials {
		data, err := client.Read(ctx, "pki/cert/"+serial)
		if err != nil {
			return count, fmt.Errorf("clientcert: reading a certificate: %w", err)
		}
		text, _ := data["certificate"].(string)
		leaf, err := parseCertificate(text)
		if err != nil {
			continue
		}
		if leaf.Subject.CommonName != did || !slices.Equal(leaf.Subject.Organization, []string{clusterhome.OwnersGroup}) || !leaf.NotAfter.After(now) {
			continue
		}
		if err := client.Write(ctx, "pki/revoke", map[string]any{"serial_number": serial}); err != nil {
			return count, fmt.Errorf("clientcert: revoking a certificate: %w", err)
		}
		count++
		if i.Recorder != nil {
			if err := i.Recorder.MarkCertificateRevoked(ctx, did, cluster, serial); err != nil {
				i.log().Warn("clientcert: the record of a revoked certificate was not updated", "serial", serial, "err", err.Error())
			}
		}
	}
	i.log().Info("clientcert: revoked kubeconfig certificates", "did", did, "cluster", cluster, "count", count)
	return count, nil
}

func FetchRevoked(ctx context.Context, logins *openbao.Logins, dataDir string) (map[string]struct{}, error) {
	ctx, cancel := context.WithTimeout(ctx, crlTimeout)
	defer cancel()
	client, _, err := clusterprovider.GatewayVaultClient(ctx, logins, dataDir)
	if err != nil {
		return nil, err
	}
	serials, err := client.List(ctx, "pki/certs/revoked")
	if err != nil {
		return nil, fmt.Errorf("clientcert: listing revoked certificates: %w", err)
	}
	out := make(map[string]struct{}, len(serials))
	for _, s := range serials {
		out[NormalizeSerial(s)] = struct{}{}
	}
	return out, nil
}

func NormalizeSerial(s string) string {
	return kcpclient.CertSerialKey(s)
}