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

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vaultlayout"
)

const (
	PKIMount    = "pki"
	PKIRoleNode = "node-client"

	CertLoginPath = "auth/cert/login"

	NodeCertFile = "vault-client.crt"
	NodeKeyFile  = "vault-client.key"
	NodeCAFile   = "vault-ca.crt"
	NodeMetaFile = "vault-client.json"

	NodeCertTTL  = 24 * time.Hour
	NodeMaxTTL   = 168 * time.Hour
	NodeTokenTTL = "15m"
	NodeTokenMax = "1h"

	ESOCertRenewWindow = NodeCertTTL / 3

	nodeFileMode = 0o600
)

var ErrNoVaultClientCA = errors.New(
	"vaultcert: the cluster vault holds no imported client CA at pki/ca/pem, so a certificate it " +
		"issues would chain to nothing and the CERT auth role would trust no issuer. Import the " +
		"cluster's client-ca into the cluster vault before minting, which is what custody does")

var ErrCertLoginRefused = errors.New("vaultcert: the CERT login was refused")

type ClientMetadata struct {
	Identity string `json:"identity"`

	Role string `json:"role"`

	VaultAddr string `json:"vaultAddr"`
}

type ClientCertificate struct {
	Metadata ClientMetadata

	CertPEM []byte

	KeyPEM []byte

	ServerCAPEM []byte

	Serial string
}

func (c ClientCertificate) WriteTo(dir string) error {
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("vaultcert: creating the certificate directory %s: %w", dir, err)
	}
	meta, err := json.Marshal(c.Metadata)
	if err != nil {
		return fmt.Errorf("vaultcert: encoding the certificate metadata: %w", err)
	}
	for _, f := range []struct {
		name string
		data []byte
	}{
		{NodeCertFile, c.CertPEM},
		{NodeKeyFile, c.KeyPEM},
		{NodeCAFile, c.ServerCAPEM},
		{NodeMetaFile, meta},
	} {
		if err := os.WriteFile(filepath.Join(dir, f.name), f.data, nodeFileMode); err != nil {
			return fmt.Errorf("vaultcert: writing %s: %w", filepath.Join(dir, f.name), err)
		}
	}
	return nil
}

func Load(dir string) (ClientCertificate, error) {
	var out ClientCertificate
	for _, f := range []struct {
		name string
		dst  *[]byte
	}{
		{NodeCertFile, &out.CertPEM},
		{NodeKeyFile, &out.KeyPEM},
		{NodeCAFile, &out.ServerCAPEM},
	} {
		raw, err := os.ReadFile(filepath.Join(dir, f.name))
		if err != nil {
			return ClientCertificate{}, fmt.Errorf("vaultcert: reading %s: %w", filepath.Join(dir, f.name), err)
		}
		*f.dst = raw
	}
	meta, err := os.ReadFile(filepath.Join(dir, NodeMetaFile))
	if err != nil {
		return ClientCertificate{}, fmt.Errorf("vaultcert: reading %s: %w", filepath.Join(dir, NodeMetaFile), err)
	}
	if err := json.Unmarshal(meta, &out.Metadata); err != nil {
		return ClientCertificate{}, fmt.Errorf("vaultcert: the metadata at %s is not JSON: %w", filepath.Join(dir, NodeMetaFile), err)
	}
	return out, nil
}

func IssueESOCertificate(ctx context.Context, client *openbao.Client, vaultAddr string, serverCAPEM []byte) (ClientCertificate, error) {
	if len(serverCAPEM) == 0 {
		return ClientCertificate{}, errors.New(
			"vaultcert: no vault server CA was given, so the certificate would carry no way to " +
				"verify the vault it dials and every read would need server verification switched off")
	}
	issued, err := client.IssueCert(ctx, PKIMount, PKIRoleNode, openbao.CertRequest{
		CommonName: vaultlayout.RoleESO,
		TTL:        vaultTTLString(NodeCertTTL),
	})
	if err != nil {
		return ClientCertificate{}, fmt.Errorf("vaultcert: issuing the certificate for %s: %w", vaultlayout.RoleESO, err)
	}
	if issued.Certificate == "" || issued.PrivateKey == "" {
		return ClientCertificate{}, fmt.Errorf("vaultcert: the vault issued an incomplete certificate for %s", vaultlayout.RoleESO)
	}
	leaf, err := parseLeaf([]byte(issued.Certificate))
	if err != nil {
		return ClientCertificate{}, err
	}
	if leaf.Subject.CommonName != vaultlayout.RoleESO {
		return ClientCertificate{}, fmt.Errorf("vaultcert: the vault issued a certificate whose common name is %q, not %q",
			leaf.Subject.CommonName, vaultlayout.RoleESO)
	}
	return ClientCertificate{
		Metadata:    ClientMetadata{Identity: vaultlayout.RoleESO, Role: vaultlayout.RoleESO, VaultAddr: vaultAddr},
		CertPEM:     []byte(issued.Certificate),
		KeyPEM:      []byte(issued.PrivateKey),
		ServerCAPEM: serverCAPEM,
		Serial:      issued.Serial,
	}, nil
}

func ESOCertificateFresh(certPEM, keyPEM []byte, now time.Time) (bool, error) {
	if len(certPEM) == 0 || len(keyPEM) == 0 {
		return false, nil
	}
	if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
		return false, err
	}
	leaf, err := parseLeaf(certPEM)
	if err != nil {
		return false, err
	}
	if leaf.Subject.CommonName != vaultlayout.RoleESO {
		return false, nil
	}
	return leaf.NotAfter.Sub(now) > ESOCertRenewWindow, nil
}

func (c ClientCertificate) Login(ctx context.Context) (string, error) {
	hc, err := c.tlsClient()
	if err != nil {
		return "", err
	}
	status, body, err := newRawClient(c.Metadata.VaultAddr, "", hc).do(ctx, http.MethodPost, CertLoginPath, map[string]any{
		"name": c.Metadata.Role,
	})
	if err != nil {
		return "", err
	}
	if status < 200 || status > 299 {
		return "", fmt.Errorf("%w: %s -> HTTP %d: %s", ErrCertLoginRefused, c.Metadata.VaultAddr, status, strings.TrimSpace(string(body)))
	}
	var payload struct {
		Auth struct {
			ClientToken string `json:"client_token"`
		} `json:"auth"`
	}
	if err := json.Unmarshal(body, &payload); err != nil {
		return "", fmt.Errorf("vaultcert: the CERT login answered a body that is not JSON: %v (%s)", err, strings.TrimSpace(string(body)))
	}
	if payload.Auth.ClientToken == "" {
		return "", fmt.Errorf("vaultcert: the CERT login answered without a client token (%s)", strings.TrimSpace(string(body)))
	}
	return payload.Auth.ClientToken, nil
}

func (c ClientCertificate) ReadKV(ctx context.Context, path string) (map[string]any, error) {
	token, err := c.Login(ctx)
	if err != nil {
		return nil, err
	}
	hc, err := c.tlsClient()
	if err != nil {
		return nil, err
	}
	status, body, err := newRawClient(c.Metadata.VaultAddr, token, hc).do(ctx, http.MethodGet, path, nil)
	if err != nil {
		return nil, err
	}
	if status < 200 || status > 299 {
		return nil, &openbao.ResponseError{Method: http.MethodGet, Path: path, Status: status, Body: strings.TrimSpace(string(body))}
	}
	var env struct {
		Data struct {
			Data map[string]any `json:"data"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil, fmt.Errorf("vaultcert: GET %s/v1/%s was not JSON: %v", c.Metadata.VaultAddr, path, err)
	}
	return env.Data.Data, nil
}

func (c ClientCertificate) FetchOwnSecret(ctx context.Context) (map[string]any, error) {
	if c.Metadata.Identity == "" {
		return nil, errors.New("vaultcert: this certificate carries no identity, so it names no per-node path to read")
	}
	return c.ReadKV(ctx, vaultlayout.NodeSecretPath(c.Metadata.Identity))
}

func (c ClientCertificate) tlsClient() (*http.Client, error) {
	if _, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM); err != nil {
		return nil, fmt.Errorf("vaultcert: the certificate and key are not a pair: %w", err)
	}
	if len(c.ServerCAPEM) == 0 {
		return nil, errors.New("vaultcert: the vault server CA carries no CERTIFICATE PEM block")
	}
	client, err := httpclient.TLSClient(httpclient.TLSOptions{CAPEM: c.ServerCAPEM, CertPEM: c.CertPEM, KeyPEM: c.KeyPEM, IdleConnTimeout: openbao.IdleConnTimeout})
	if errors.Is(err, httpclient.ErrNoCA) {
		return nil, errors.New("vaultcert: the vault server CA carries no CERTIFICATE PEM block")
	}
	return client, err
}

type rawClient struct {
	addr  string
	token string
	http  *http.Client
}

func newRawClient(addr, token string, hc *http.Client) *rawClient {
	if hc == nil {
		hc = http.DefaultClient
	}
	return &rawClient{addr: strings.TrimRight(addr, "/"), token: token, http: hc}
}

func (c *rawClient) do(ctx context.Context, method, path string, in any) (int, []byte, error) {
	status, body, err := openbao.New(c.addr, c.token, openbao.WithHTTPClient(c.http)).Raw(ctx, method, strings.Trim(path, "/"), in)
	if err != nil {
		return 0, nil, fmt.Errorf("vaultcert: %s /v1/%s: %w", method, path, err)
	}
	return status, body, nil
}

func vaultTTLString(d time.Duration) string {
	return fmt.Sprintf("%dh", int(d.Hours()))
}

func parseLeaf(certPEM []byte) (*x509.Certificate, error) {
	certs, err := parseCertificates(certPEM)
	if err != nil {
		return nil, err
	}
	if len(certs) == 0 {
		return nil, errors.New("vaultcert: the certificate holds no PEM block")
	}
	return certs[0], nil
}

func parseCertificates(certPEM []byte) ([]*x509.Certificate, error) {
	var out []*x509.Certificate
	for block, rest := pem.Decode(certPEM); block != nil; block, rest = pem.Decode(rest) {
		if block.Type != "CERTIFICATE" {
			continue
		}
		cert, err := x509.ParseCertificate(block.Bytes)
		if err != nil {
			return nil, fmt.Errorf("vaultcert: a PEM block is not a certificate: %w", err)
		}
		out = append(out, cert)
	}
	return out, nil
}