~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
344 lines · 9.9 KB Raw History
package openbao

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

	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/vault"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/lease"
)

type CertRequest struct {
	CommonName string
	AltNames   []string
	IPSANs     []string
	TTL        string

	ExcludeCNFromSANs bool

	NotAfter string
}

type Cert struct {
	Certificate string
	PrivateKey  string
	IssuingCA   string
	CAChain     []string
	Serial      string
}

type PKIRole struct {
	AllowedDomains  []string
	AllowSubdomains bool
	MaxTTL          string
}

func (c *Client) IssueCert(ctx context.Context, pkiPath, role string, req CertRequest) (Cert, error) {
	body := map[string]any{"common_name": req.CommonName}
	if len(req.AltNames) > 0 {
		body["alt_names"] = strings.Join(req.AltNames, ",")
	}
	if len(req.IPSANs) > 0 {
		body["ip_sans"] = strings.Join(req.IPSANs, ",")
	}
	if req.TTL != "" {
		body["ttl"] = req.TTL
	}
	if req.ExcludeCNFromSANs {
		body["exclude_cn_from_sans"] = true
	}
	if req.NotAfter != "" {
		body["not_after"] = req.NotAfter
	}
	raw, err := c.do(ctx, http.MethodPost, strings.Trim(pkiPath, "/")+"/issue/"+strings.Trim(role, "/"), body)
	if err != nil {
		return Cert{}, err
	}
	return parseCert(raw)
}

func (c *Client) ConfigurePKIURLs(ctx context.Context, pkiPath, issuingCertificates, crlDistributionPoints string) error {
	_, err := c.do(ctx, http.MethodPost, strings.Trim(pkiPath, "/")+"/config/urls", map[string]any{
		"issuing_certificates":    issuingCertificates,
		"crl_distribution_points": crlDistributionPoints,
	})
	return err
}

func (c *Client) GeneratePKIRoot(ctx context.Context, pkiPath, commonName, ttl string) (Cert, error) {
	body := map[string]any{"common_name": commonName}
	if ttl != "" {
		body["ttl"] = ttl
	}
	raw, err := c.do(ctx, http.MethodPost, strings.Trim(pkiPath, "/")+"/root/generate/internal", body)
	if err != nil {
		return Cert{}, err
	}
	return parseCert(raw)
}

func (c *Client) WritePKIRole(ctx context.Context, pkiPath, role string, cfg PKIRole) error {
	body := map[string]any{
		"allowed_domains":  cfg.AllowedDomains,
		"allow_subdomains": cfg.AllowSubdomains,
	}
	if cfg.MaxTTL != "" {
		body["max_ttl"] = cfg.MaxTTL
	}
	_, err := c.do(ctx, http.MethodPost, strings.Trim(pkiPath, "/")+"/roles/"+strings.Trim(role, "/"), body)
	return err
}

func (c *Client) CAPEM(ctx context.Context, pkiMount string) (string, error) {
	return c.rawPEM(ctx, strings.Trim(pkiMount, "/")+"/ca/pem")
}

func (c *Client) CAChainPEM(ctx context.Context, pkiMount string) (string, error) {
	return c.rawPEM(ctx, strings.Trim(pkiMount, "/")+"/ca_chain")
}

func (c *Client) rawPEM(ctx context.Context, path string) (string, error) {
	status, body, err := c.request(ctx, http.MethodGet, path, nil)
	if err != nil {
		return "", err
	}
	if status < 200 || status > 299 {
		return "", &ResponseError{Method: http.MethodGet, Path: path, Status: status, Body: strings.TrimSpace(string(body))}
	}
	out := strings.TrimSpace(string(body))
	if out == "" {
		return "", fmt.Errorf("openbao: %s holds no certificate", path)
	}
	return out + "\n", nil
}

func parseCert(raw json.RawMessage) (Cert, error) {
	var payload struct {
		Certificate  string   `json:"certificate"`
		PrivateKey   string   `json:"private_key"`
		IssuingCA    string   `json:"issuing_ca"`
		CAChain      []string `json:"ca_chain"`
		SerialNumber string   `json:"serial_number"`
	}
	if err := json.Unmarshal(raw, &payload); err != nil {
		return Cert{}, fmt.Errorf("openbao: a PKI response was not JSON: %v", err)
	}
	return Cert{Certificate: payload.Certificate, PrivateKey: payload.PrivateKey, IssuingCA: payload.IssuingCA,
		CAChain: payload.CAChain, Serial: payload.SerialNumber}, nil
}

func (c *Client) Namespace(namespace string) *Client {
	out := c.with(c.token, c.renews)
	if out.api != nil {
		out.api = out.api.WithNamespace(namespace)
	}
	return out
}

type Backend struct {
	Client *Client
}

var _ vault.PKIBackend = Backend{}

func (b Backend) in(namespace string) *Client {
	if namespace == "" {
		return b.Client
	}
	return b.Client.Namespace(namespace)
}

func (b Backend) EnsureNamespace(ctx context.Context, path string) error {
	if path == "" {
		return nil
	}
	_, err := b.Client.Read(ctx, "sys/namespaces/"+path)
	if err == nil {
		return nil
	}
	if !errors.Is(err, ErrNotFound) {
		return err
	}
	return b.Client.Write(ctx, "sys/namespaces/"+path, map[string]any{})
}

func (b Backend) DeleteNamespace(ctx context.Context, path string) error {
	return b.Client.Delete(ctx, "sys/namespaces/"+path)
}

func (b Backend) EnsureMount(ctx context.Context, namespace, path, kind string) error {
	c := b.in(namespace)
	mounts, err := c.SecretMounts(ctx)
	if err != nil {
		return err
	}
	key := strings.Trim(path, "/")
	if info, ok := mounts[key]; ok {
		if info.Type != kind {
			return fmt.Errorf("openbao: %s in namespace %q is mounted as %q, not %q, and this will not replace a mount in use",
				key, namespace, info.Type, kind)
		}
		return nil
	}
	return c.enableMount(ctx, key, map[string]any{"type": kind})
}

func (b Backend) GenerateRoot(ctx context.Context, namespace, mount, commonName, ttl string) (vault.RootCA, error) {
	cert, err := b.in(namespace).GeneratePKIRoot(ctx, mount, commonName, ttl)
	if err != nil {
		return vault.RootCA{}, err
	}
	out := vault.RootCA{Certificate: cert.Certificate, Serial: cert.Serial}
	if out.Serial == "" {
		out.Serial = serialOfPEM(out.Certificate)
	}
	return out, nil
}

func (b Backend) CASerial(ctx context.Context, namespace, mount string) (string, error) {
	data, err := b.in(namespace).Read(ctx, strings.Trim(mount, "/")+"/cert/ca")
	if err != nil {
		var response *ResponseError
		if errors.Is(err, ErrNotFound) ||
			(errors.As(err, &response) && response.Status == http.StatusBadRequest && strings.Contains(response.Body, "no default issuer")) {
			return "", fmt.Errorf("%w: %w: no CA at %s", vault.ErrNoAuthority, ErrNotFound, mount)
		}
		return "", err
	}
	certificate, _ := data["certificate"].(string)
	if certificate == "" {
		return "", fmt.Errorf("%w: %w: no CA at %s", vault.ErrNoAuthority, ErrNotFound, mount)
	}
	return serialOfPEM(certificate), nil
}

func (b Backend) CAChain(ctx context.Context, namespace, mount string) (string, error) {
	chain, err := b.in(namespace).CAChainPEM(ctx, mount)
	return strings.TrimSpace(chain), err
}

func (b Backend) GenerateIntermediate(ctx context.Context, namespace, mount, commonName string) (vault.IntermediateCSR, error) {
	data, err := b.in(namespace).WriteData(ctx, strings.Trim(mount, "/")+"/intermediate/generate/internal", map[string]any{
		"common_name": commonName,
		"key_type":    "ec",
		"key_bits":    256,
	})
	if err != nil {
		return vault.IntermediateCSR{}, err
	}
	return vault.IntermediateCSR{CSR: stringField(data, "csr"), PrivateKey: stringField(data, "private_key")}, nil
}

func (b Backend) SignIntermediate(ctx context.Context, rootNamespace, mount, csr, commonName, ttl string) (string, error) {
	body := map[string]any{"csr": csr, "common_name": commonName}
	if ttl != "" {
		body["ttl"] = ttl
	}
	data, err := b.in(rootNamespace).WriteData(ctx, strings.Trim(mount, "/")+"/root/sign-intermediate", body)
	if err != nil {
		return "", err
	}
	var chain strings.Builder
	chain.WriteString(stringField(data, "certificate"))
	for _, entry := range stringList(data, "ca_chain") {
		chain.WriteString("\n" + entry)
	}
	return chain.String(), nil
}

func (b Backend) SetSignedIntermediate(ctx context.Context, namespace, mount, chain string) error {
	return b.in(namespace).Write(ctx, strings.Trim(mount, "/")+"/intermediate/set-signed", map[string]any{"certificate": chain})
}

func (b Backend) WriteRole(ctx context.Context, namespace, mount, name string, role vault.Role) error {
	body := map[string]any{
		"allow_subdomains":   role.AllowSubdomains,
		"allow_bare_domains": role.AllowBareDomains,
		"enforce_hostnames":  role.EnforceHostnames,
		"allow_ip_sans":      true,
		"no_store":           role.NoStore,
	}
	if len(role.AllowedDomains) > 0 {
		body["allowed_domains"] = role.AllowedDomains
	}
	if role.KeyType != "" {
		body["key_type"] = role.KeyType
	}
	if role.KeyBits > 0 {
		body["key_bits"] = role.KeyBits
	}
	if role.MaxTTL != "" {
		body["max_ttl"] = role.MaxTTL
	}
	return b.in(namespace).Write(ctx, strings.Trim(mount, "/")+"/roles/"+strings.Trim(name, "/"), body)
}

func (b Backend) Issue(ctx context.Context, namespace, mount, role string, req vault.CertRequest) (vault.Cert, error) {
	cert, err := b.in(namespace).IssueCert(ctx, mount, role, CertRequest{CommonName: req.CommonName, AltNames: req.AltNames,
		IPSANs: req.IPSANs, TTL: req.TTL})
	if err != nil {
		return vault.Cert{}, err
	}
	return vault.Cert{Certificate: cert.Certificate, PrivateKey: cert.PrivateKey, IssuingCA: cert.IssuingCA,
		CAChain: cert.CAChain, Serial: cert.Serial}, nil
}

func CAPoolFetch(pki vault.PKI, mount string, lifetime time.Duration) lease.Fetch[*x509.CertPool] {
	return func(ctx context.Context) (*x509.CertPool, time.Time, error) {
		chain, err := pki.CAChain(ctx, mount)
		if err != nil {
			return nil, time.Time{}, err
		}
		pool, err := CertPoolFromPEM([]byte(chain))
		if err != nil {
			return nil, time.Time{}, err
		}
		return pool, time.Now().Add(lifetime), nil
	}
}

func serialOfPEM(body string) string {
	block, _ := pem.Decode([]byte(body))
	if block == nil {
		return ""
	}
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return ""
	}
	hex := strings.ToUpper(cert.SerialNumber.Text(16))
	if len(hex)%2 == 1 {
		hex = "0" + hex
	}
	pairs := make([]string, 0, len(hex)/2)
	for i := 0; i < len(hex); i += 2 {
		pairs = append(pairs, hex[i:i+2])
	}
	return strings.Join(pairs, ":")
}

func stringField(data map[string]any, key string) string {
	value, _ := data[key].(string)
	return value
}

func stringList(data map[string]any, key string) []string {
	raw, ok := data[key].([]any)
	if !ok {
		return nil
	}
	out := make([]string, 0, len(raw))
	for _, entry := range raw {
		if value, ok := entry.(string); ok {
			out = append(out, value)
		}
	}
	return out
}