Files
metacrypt/internal/engine/ca/ca.go
Kyle Isom 0f1d58a9b8 Persist engine mounts across seal/unseal cycles
- Add Registry.UnsealAll() that rediscovers mounted engines from the
  barrier on unseal, using stored metadata at engine/_mounts/ with a
  fallback discovery scan for pre-existing mounts (migration path)
- Registry.Mount() now persists mount metadata to the barrier;
  Registry.Unmount() cleans it up
- Call UnsealAll() from both REST and web unseal handlers
- Change Unmount() signature to accept context.Context
- Default CA key size changed from P-384 to P-521
- Add build-time version stamp via ldflags; display in dashboard status bar
- Make metacrypt target .PHONY so make devserver always rebuilds
- Redirect /pki to /dashboard when no CA engine is mounted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:47:48 -07:00

1114 lines
28 KiB
Go

// Package ca implements the CA (PKI) engine for X.509 certificate issuance.
package ca
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"git.wntrmute.dev/kyle/goutils/certlib/certgen"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
var (
ErrSealed = errors.New("ca: engine is sealed")
ErrIssuerNotFound = errors.New("ca: issuer not found")
ErrIssuerExists = errors.New("ca: issuer already exists")
ErrCertNotFound = errors.New("ca: certificate not found")
ErrUnknownProfile = errors.New("ca: unknown profile")
ErrForbidden = errors.New("ca: forbidden")
ErrUnauthorized = errors.New("ca: authentication required")
)
// issuerState holds in-memory state for a loaded issuer.
type issuerState struct {
cert *x509.Certificate
key crypto.PrivateKey
config *IssuerConfig
}
// CAEngine implements the CA (PKI) engine.
type CAEngine struct {
mu sync.RWMutex
barrier barrier.Barrier
mountPath string
config *CAConfig
rootCert *x509.Certificate
rootKey crypto.PrivateKey
issuers map[string]*issuerState
}
// NewCAEngine creates a new CA engine instance.
func NewCAEngine() engine.Engine {
return &CAEngine{
issuers: make(map[string]*issuerState),
}
}
func (e *CAEngine) Type() engine.EngineType {
return engine.EngineTypeCA
}
// Initialize sets up the CA engine for first use: generates a self-signed root CA.
func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error {
e.mu.Lock()
defer e.mu.Unlock()
e.barrier = b
e.mountPath = mountPath
cfg := defaultCAConfig()
if config != nil {
if err := mapToCAConfig(config, cfg); err != nil {
return fmt.Errorf("ca: parse config: %w", err)
}
}
e.config = cfg
// Store config.
configData, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("ca: marshal config: %w", err)
}
if err := b.Put(ctx, mountPath+"config.json", configData); err != nil {
return fmt.Errorf("ca: store config: %w", err)
}
var rootCert *x509.Certificate
var rootKey crypto.PrivateKey
var certPEM, keyPEM []byte
// If root_cert_pem and root_key_pem are provided, import them
// instead of generating a new root CA.
rootCertStr, _ := config["root_cert_pem"].(string)
rootKeyStr, _ := config["root_key_pem"].(string)
if rootCertStr != "" && rootKeyStr != "" {
certPEM = []byte(rootCertStr)
keyPEM = []byte(rootKeyStr)
var err error
rootCert, err = parseCertPEM(certPEM)
if err != nil {
return fmt.Errorf("ca: parse imported root cert: %w", err)
}
if !rootCert.IsCA {
return fmt.Errorf("ca: imported certificate is not a CA")
}
rootKey, err = parsePrivateKeyPEM(keyPEM)
if err != nil {
return fmt.Errorf("ca: parse imported root key: %w", err)
}
} else {
// Generate self-signed root CA.
creq := &certgen.CertificateRequest{
KeySpec: certgen.KeySpec{
Algorithm: cfg.KeyAlgorithm,
Size: cfg.KeySize,
},
Subject: certgen.Subject{
CommonName: cfg.Organization + " Root CA",
Organization: cfg.Organization,
Country: cfg.Country,
},
Profile: certgen.Profile{
IsCA: true,
PathLen: 1,
KeyUse: []string{"cert sign", "crl sign"},
Expiry: cfg.RootExpiry,
},
}
var err error
rootCert, rootKey, err = certgen.GenerateSelfSigned(creq)
if err != nil {
return fmt.Errorf("ca: generate root CA: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw})
keyPEM, err = marshalPrivateKey(rootKey)
if err != nil {
return fmt.Errorf("ca: marshal root key: %w", err)
}
}
if err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil {
return fmt.Errorf("ca: store root cert: %w", err)
}
if err := b.Put(ctx, mountPath+"root/key.pem", keyPEM); err != nil {
return fmt.Errorf("ca: store root key: %w", err)
}
e.rootCert = rootCert
e.rootKey = rootKey
return nil
}
// Unseal loads the CA state from the barrier into memory.
func (e *CAEngine) Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.barrier = b
e.mountPath = mountPath
// Load config.
configData, err := b.Get(ctx, mountPath+"config.json")
if err != nil {
return fmt.Errorf("ca: load config: %w", err)
}
var cfg CAConfig
if err := json.Unmarshal(configData, &cfg); err != nil {
return fmt.Errorf("ca: parse config: %w", err)
}
e.config = &cfg
// Load root cert and key.
certPEM, err := b.Get(ctx, mountPath+"root/cert.pem")
if err != nil {
return fmt.Errorf("ca: load root cert: %w", err)
}
keyPEM, err := b.Get(ctx, mountPath+"root/key.pem")
if err != nil {
return fmt.Errorf("ca: load root key: %w", err)
}
rootCert, err := parseCertPEM(certPEM)
if err != nil {
return fmt.Errorf("ca: parse root cert: %w", err)
}
rootKey, err := parsePrivateKeyPEM(keyPEM)
if err != nil {
return fmt.Errorf("ca: parse root key: %w", err)
}
e.rootCert = rootCert
e.rootKey = rootKey
e.issuers = make(map[string]*issuerState)
// Load all issuers.
issuerPaths, err := b.List(ctx, mountPath+"issuers/")
if err != nil {
return fmt.Errorf("ca: list issuers: %w", err)
}
// Collect unique issuer names from paths like "name/cert.pem", "name/key.pem", "name/config.json".
issuerNames := make(map[string]bool)
for _, p := range issuerPaths {
parts := strings.SplitN(p, "/", 2)
if len(parts) > 0 && parts[0] != "" {
issuerNames[parts[0]] = true
}
}
for name := range issuerNames {
is, err := e.loadIssuer(ctx, b, mountPath, name)
if err != nil {
return fmt.Errorf("ca: load issuer %q: %w", name, err)
}
e.issuers[name] = is
}
return nil
}
func (e *CAEngine) loadIssuer(ctx context.Context, b barrier.Barrier, mountPath, name string) (*issuerState, error) {
prefix := mountPath + "issuers/" + name + "/"
certPEM, err := b.Get(ctx, prefix+"cert.pem")
if err != nil {
return nil, fmt.Errorf("load cert: %w", err)
}
keyPEM, err := b.Get(ctx, prefix+"key.pem")
if err != nil {
return nil, fmt.Errorf("load key: %w", err)
}
configData, err := b.Get(ctx, prefix+"config.json")
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
cert, err := parseCertPEM(certPEM)
if err != nil {
return nil, fmt.Errorf("parse cert: %w", err)
}
key, err := parsePrivateKeyPEM(keyPEM)
if err != nil {
return nil, fmt.Errorf("parse key: %w", err)
}
var cfg IssuerConfig
if err := json.Unmarshal(configData, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &issuerState{cert: cert, key: key, config: &cfg}, nil
}
// Seal zeroizes all in-memory key material.
func (e *CAEngine) Seal() error {
e.mu.Lock()
defer e.mu.Unlock()
zeroizeKey(e.rootKey)
e.rootKey = nil
e.rootCert = nil
e.config = nil
for name, is := range e.issuers {
zeroizeKey(is.key)
delete(e.issuers, name)
}
e.issuers = nil
return nil
}
// HandleRequest dispatches CA operations.
func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
switch req.Operation {
case "get-root":
return e.handleGetRoot(ctx)
case "get-chain":
return e.handleGetChain(ctx, req)
case "get-issuer":
return e.handleGetIssuer(ctx, req)
case "create-issuer":
return e.handleCreateIssuer(ctx, req)
case "delete-issuer":
return e.handleDeleteIssuer(ctx, req)
case "list-issuers":
return e.handleListIssuers(ctx, req)
case "issue":
return e.handleIssue(ctx, req)
case "get-cert":
return e.handleGetCert(ctx, req)
case "list-certs":
return e.handleListCerts(ctx, req)
case "renew":
return e.handleRenew(ctx, req)
case "import-root":
return e.handleImportRoot(ctx, req)
default:
return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation)
}
}
// --- Public methods for unauthenticated PKI routes ---
// GetRootCertPEM returns the root CA certificate in PEM format.
func (e *CAEngine) GetRootCertPEM() ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw}), nil
}
// GetIssuerCertPEM returns the named issuer's certificate in PEM format.
func (e *CAEngine) GetIssuerCertPEM(name string) ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, ok := e.issuers[name]
if !ok {
return nil, ErrIssuerNotFound
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw}), nil
}
// GetChainPEM returns the full certificate chain (issuer + root) in PEM format.
func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, ok := e.issuers[issuerName]
if !ok {
return nil, ErrIssuerNotFound
}
var chain []byte
chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...)
chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...)
return chain, nil
}
// --- Operation handlers ---
func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
certStr, _ := req.Data["cert_pem"].(string)
keyStr, _ := req.Data["key_pem"].(string)
if certStr == "" || keyStr == "" {
return nil, fmt.Errorf("ca: cert_pem and key_pem are required")
}
newCert, err := parseCertPEM([]byte(certStr))
if err != nil {
return nil, fmt.Errorf("ca: parse imported cert: %w", err)
}
if !newCert.IsCA {
return nil, fmt.Errorf("ca: imported certificate is not a CA")
}
newKey, err := parsePrivateKeyPEM([]byte(keyStr))
if err != nil {
return nil, fmt.Errorf("ca: parse imported key: %w", err)
}
e.mu.Lock()
defer e.mu.Unlock()
// Only allow import if there is no root or the current root is expired.
if e.rootCert != nil && time.Now().Before(e.rootCert.NotAfter) {
return nil, fmt.Errorf("ca: current root is still valid (expires %s); cannot replace", e.rootCert.NotAfter.Format(time.RFC3339))
}
// Zeroize old key if present.
if e.rootKey != nil {
zeroizeKey(e.rootKey)
}
// Store in barrier.
certPEM := []byte(certStr)
keyPEM := []byte(keyStr)
if err := e.barrier.Put(ctx, e.mountPath+"root/cert.pem", certPEM); err != nil {
return nil, fmt.Errorf("ca: store root cert: %w", err)
}
if err := e.barrier.Put(ctx, e.mountPath+"root/key.pem", keyPEM); err != nil {
return nil, fmt.Errorf("ca: store root key: %w", err)
}
e.rootCert = newCert
e.rootKey = newKey
return &engine.Response{
Data: map[string]interface{}{
"cn": newCert.Subject.CommonName,
"expires_at": newCert.NotAfter,
},
}, nil
}
func (e *CAEngine) handleGetRoot(_ context.Context) (*engine.Response, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})
return &engine.Response{
Data: map[string]interface{}{
"cert_pem": string(certPEM),
},
}, nil
}
func (e *CAEngine) handleGetChain(_ context.Context, req *engine.Request) (*engine.Response, error) {
issuerName, _ := req.Data["issuer"].(string)
if issuerName == "" {
issuerName = req.Path
}
chain, err := e.GetChainPEM(issuerName)
if err != nil {
return nil, err
}
return &engine.Response{
Data: map[string]interface{}{
"chain_pem": string(chain),
},
}, nil
}
func (e *CAEngine) handleGetIssuer(_ context.Context, req *engine.Request) (*engine.Response, error) {
name := req.Path
certPEM, err := e.GetIssuerCertPEM(name)
if err != nil {
return nil, err
}
return &engine.Response{
Data: map[string]interface{}{
"cert_pem": string(certPEM),
},
}, nil
}
func (e *CAEngine) handleCreateIssuer(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
e.mu.Lock()
defer e.mu.Unlock()
if e.rootCert == nil {
return nil, ErrSealed
}
if _, exists := e.issuers[name]; exists {
return nil, ErrIssuerExists
}
// Determine key spec: use issuer-specific overrides or fall back to CA config.
keyAlg := e.config.KeyAlgorithm
keySize := e.config.KeySize
if v, ok := req.Data["key_algorithm"].(string); ok && v != "" {
keyAlg = v
}
if v, ok := req.Data["key_size"].(float64); ok {
keySize = int(v)
}
expiry := "43800h" // 5 years default
if v, ok := req.Data["expiry"].(string); ok && v != "" {
expiry = v
}
maxTTL := "2160h" // 90 days default
if v, ok := req.Data["max_ttl"].(string); ok && v != "" {
maxTTL = v
}
// Generate issuer key pair and CSR.
ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize}
_, priv, err := ks.Generate()
if err != nil {
return nil, fmt.Errorf("ca: generate issuer key: %w", err)
}
creq := certgen.CertificateRequest{
KeySpec: ks,
Subject: certgen.Subject{
CommonName: name,
Organization: e.config.Organization,
Country: e.config.Country,
},
}
csr, err := creq.Request(priv)
if err != nil {
return nil, fmt.Errorf("ca: create issuer CSR: %w", err)
}
// Sign with root CA using an intermediate CA profile.
profile := certgen.Profile{
IsCA: true,
PathLen: 0,
KeyUse: []string{"cert sign", "crl sign"},
Expiry: expiry,
}
issuerCert, err := profile.SignRequest(e.rootCert, csr, e.rootKey)
if err != nil {
return nil, fmt.Errorf("ca: sign issuer cert: %w", err)
}
// Store in barrier.
prefix := e.mountPath + "issuers/" + name + "/"
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerCert.Raw})
keyPEM, err := marshalPrivateKey(priv)
if err != nil {
return nil, fmt.Errorf("ca: marshal issuer key: %w", err)
}
issuerCfg := &IssuerConfig{
Name: name,
KeyAlgorithm: keyAlg,
KeySize: keySize,
Expiry: expiry,
MaxTTL: maxTTL,
CreatedBy: req.CallerInfo.Username,
CreatedAt: time.Now(),
}
cfgData, err := json.Marshal(issuerCfg)
if err != nil {
return nil, fmt.Errorf("ca: marshal issuer config: %w", err)
}
if err := e.barrier.Put(ctx, prefix+"cert.pem", certPEM); err != nil {
return nil, fmt.Errorf("ca: store issuer cert: %w", err)
}
if err := e.barrier.Put(ctx, prefix+"key.pem", keyPEM); err != nil {
return nil, fmt.Errorf("ca: store issuer key: %w", err)
}
if err := e.barrier.Put(ctx, prefix+"config.json", cfgData); err != nil {
return nil, fmt.Errorf("ca: store issuer config: %w", err)
}
e.issuers[name] = &issuerState{cert: issuerCert, key: priv, config: issuerCfg}
return &engine.Response{
Data: map[string]interface{}{
"name": name,
"cert_pem": string(certPEM),
},
}, nil
}
func (e *CAEngine) handleDeleteIssuer(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
name = req.Path
}
e.mu.Lock()
defer e.mu.Unlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, exists := e.issuers[name]
if !exists {
return nil, ErrIssuerNotFound
}
// Zeroize key material.
zeroizeKey(is.key)
// Delete from barrier.
prefix := e.mountPath + "issuers/" + name + "/"
for _, suffix := range []string{"cert.pem", "key.pem", "config.json"} {
if err := e.barrier.Delete(ctx, prefix+suffix); err != nil {
return nil, fmt.Errorf("ca: delete issuer %s: %w", suffix, err)
}
}
delete(e.issuers, name)
return &engine.Response{
Data: map[string]interface{}{"ok": true},
}, nil
}
func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
e.mu.RLock()
defer e.mu.RUnlock()
if e.rootCert == nil {
return nil, ErrSealed
}
names := make([]string, 0, len(e.issuers))
for name := range e.issuers {
names = append(names, name)
}
return &engine.Response{
Data: map[string]interface{}{
"issuers": names,
},
}, nil
}
func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
issuerName, _ := req.Data["issuer"].(string)
if issuerName == "" {
issuerName = req.Path
}
if issuerName == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
profileName, _ := req.Data["profile"].(string)
if profileName == "" {
profileName = "server"
}
cn, _ := req.Data["common_name"].(string)
if cn == "" {
return nil, fmt.Errorf("ca: common_name is required")
}
e.mu.Lock()
defer e.mu.Unlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, ok := e.issuers[issuerName]
if !ok {
return nil, ErrIssuerNotFound
}
profile, ok := GetProfile(profileName)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
}
// Apply user overrides.
if v, ok := req.Data["ttl"].(string); ok && v != "" {
profile.Expiry = v
}
if v, ok := req.Data["key_usages"].([]interface{}); ok {
profile.KeyUse = toStringSlice(v)
}
if v, ok := req.Data["ext_key_usages"].([]interface{}); ok {
profile.ExtKeyUsages = toStringSlice(v)
}
// Determine leaf key spec.
keyAlg := is.config.KeyAlgorithm
keySize := is.config.KeySize
if v, ok := req.Data["key_algorithm"].(string); ok && v != "" {
keyAlg = v
}
if v, ok := req.Data["key_size"].(float64); ok {
keySize = int(v)
}
// Parse SANs.
var dnsNames []string
var ipAddrs []string
if v, ok := req.Data["dns_names"].([]interface{}); ok {
dnsNames = toStringSlice(v)
}
if v, ok := req.Data["ip_addresses"].([]interface{}); ok {
ipAddrs = toStringSlice(v)
}
// Generate leaf key pair and CSR.
ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize}
_, leafKey, err := ks.Generate()
if err != nil {
return nil, fmt.Errorf("ca: generate leaf key: %w", err)
}
creq := certgen.CertificateRequest{
KeySpec: ks,
Subject: certgen.Subject{
CommonName: cn,
Organization: e.config.Organization,
DNSNames: dnsNames,
IPAddresses: ipAddrs,
},
}
csr, err := creq.Request(leafKey)
if err != nil {
return nil, fmt.Errorf("ca: create leaf CSR: %w", err)
}
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign leaf cert: %w", err)
}
// Build PEM outputs.
leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw})
leafKeyPEM, err := marshalPrivateKey(leafKey)
if err != nil {
return nil, fmt.Errorf("ca: marshal leaf key: %w", err)
}
var chainPEM []byte
chainPEM = append(chainPEM, leafCertPEM...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...)
serial := fmt.Sprintf("%x", leafCert.SerialNumber)
// Collect all SANs for the record.
allSANs := append(leafCert.DNSNames, ipStrings(leafCert.IPAddresses)...)
// Store cert record (NO private key).
record := &CertRecord{
Serial: serial,
Issuer: issuerName,
CN: cn,
SANs: allSANs,
Profile: profileName,
CertPEM: string(leafCertPEM),
IssuedBy: req.CallerInfo.Username,
IssuedAt: time.Now(),
ExpiresAt: leafCert.NotAfter,
}
recordData, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("ca: marshal cert record: %w", err)
}
if err := e.barrier.Put(ctx, e.mountPath+"certs/"+serial+".json", recordData); err != nil {
return nil, fmt.Errorf("ca: store cert record: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{
"serial": serial,
"cert_pem": string(leafCertPEM),
"key_pem": string(leafKeyPEM),
"chain_pem": string(chainPEM),
"cn": cn,
"sans": allSANs,
"issued_by": req.CallerInfo.Username,
"expires_at": leafCert.NotAfter,
},
}, nil
}
func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
serial, _ := req.Data["serial"].(string)
if serial == "" {
serial = req.Path
}
if serial == "" {
return nil, fmt.Errorf("ca: serial is required")
}
e.mu.RLock()
defer e.mu.RUnlock()
recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json")
if err != nil {
if errors.Is(err, barrier.ErrNotFound) {
return nil, ErrCertNotFound
}
return nil, fmt.Errorf("ca: load cert record: %w", err)
}
var record CertRecord
if err := json.Unmarshal(recordData, &record); err != nil {
return nil, fmt.Errorf("ca: parse cert record: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{
"serial": record.Serial,
"issuer": record.Issuer,
"cn": record.CN,
"sans": record.SANs,
"profile": record.Profile,
"cert_pem": record.CertPEM,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
},
}, nil
}
func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
e.mu.RLock()
defer e.mu.RUnlock()
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
if err != nil {
return nil, fmt.Errorf("ca: list certs: %w", err)
}
var certs []map[string]interface{}
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
if err != nil {
continue
}
var record CertRecord
if err := json.Unmarshal(recordData, &record); err != nil {
continue
}
certs = append(certs, map[string]interface{}{
"serial": record.Serial,
"issuer": record.Issuer,
"cn": record.CN,
"profile": record.Profile,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
})
}
return &engine.Response{
Data: map[string]interface{}{
"certs": certs,
},
}, nil
}
func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
serial, _ := req.Data["serial"].(string)
if serial == "" {
serial = req.Path
}
if serial == "" {
return nil, fmt.Errorf("ca: serial is required")
}
e.mu.Lock()
defer e.mu.Unlock()
if e.rootCert == nil {
return nil, ErrSealed
}
// Load original cert record.
recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json")
if err != nil {
if errors.Is(err, barrier.ErrNotFound) {
return nil, ErrCertNotFound
}
return nil, fmt.Errorf("ca: load cert record: %w", err)
}
var record CertRecord
if err := json.Unmarshal(recordData, &record); err != nil {
return nil, fmt.Errorf("ca: parse cert record: %w", err)
}
// Look up issuer.
is, ok := e.issuers[record.Issuer]
if !ok {
return nil, fmt.Errorf("ca: original issuer %q no longer exists", record.Issuer)
}
// Parse original cert to extract attributes.
origCert, err := parseCertPEM([]byte(record.CertPEM))
if err != nil {
return nil, fmt.Errorf("ca: parse original cert: %w", err)
}
// Build profile from original cert's usages.
profile, _ := GetProfile(record.Profile)
// Use original TTL duration.
origDuration := origCert.NotAfter.Sub(origCert.NotBefore)
profile.Expiry = fmt.Sprintf("%dh", int(origDuration.Hours()))
// Generate new key.
ks := certgen.KeySpec{Algorithm: is.config.KeyAlgorithm, Size: is.config.KeySize}
_, newKey, err := ks.Generate()
if err != nil {
return nil, fmt.Errorf("ca: generate renewal key: %w", err)
}
creq := certgen.CertificateRequest{
KeySpec: ks,
Subject: certgen.Subject{
CommonName: origCert.Subject.CommonName,
Organization: firstOrEmpty(origCert.Subject.Organization),
DNSNames: origCert.DNSNames,
IPAddresses: ipStrings(origCert.IPAddresses),
},
}
csr, err := creq.Request(newKey)
if err != nil {
return nil, fmt.Errorf("ca: create renewal CSR: %w", err)
}
newCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign renewal cert: %w", err)
}
// Build PEMs.
newCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newCert.Raw})
newKeyPEM, err := marshalPrivateKey(newKey)
if err != nil {
return nil, fmt.Errorf("ca: marshal renewal key: %w", err)
}
var chainPEM []byte
chainPEM = append(chainPEM, newCertPEM...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...)
newSerial := fmt.Sprintf("%x", newCert.SerialNumber)
allSANs := append(newCert.DNSNames, ipStrings(newCert.IPAddresses)...)
newRecord := &CertRecord{
Serial: newSerial,
Issuer: record.Issuer,
CN: record.CN,
SANs: allSANs,
Profile: record.Profile,
CertPEM: string(newCertPEM),
IssuedBy: req.CallerInfo.Username,
IssuedAt: time.Now(),
ExpiresAt: newCert.NotAfter,
}
newRecordData, err := json.Marshal(newRecord)
if err != nil {
return nil, fmt.Errorf("ca: marshal renewal record: %w", err)
}
if err := e.barrier.Put(ctx, e.mountPath+"certs/"+newSerial+".json", newRecordData); err != nil {
return nil, fmt.Errorf("ca: store renewal record: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{
"serial": newSerial,
"cert_pem": string(newCertPEM),
"key_pem": string(newKeyPEM),
"chain_pem": string(chainPEM),
"cn": record.CN,
"expires_at": newCert.NotAfter,
},
}, nil
}
// --- Helpers ---
func defaultCAConfig() *CAConfig {
return &CAConfig{
Organization: "Metacircular",
KeyAlgorithm: "ecdsa",
KeySize: 521,
RootExpiry: "87600h", // 10 years
}
}
func mapToCAConfig(m map[string]interface{}, cfg *CAConfig) error {
if v, ok := m["organization"].(string); ok {
cfg.Organization = v
}
if v, ok := m["country"].(string); ok {
cfg.Country = v
}
if v, ok := m["key_algorithm"].(string); ok {
cfg.KeyAlgorithm = v
}
if v, ok := m["key_size"].(float64); ok {
cfg.KeySize = int(v)
}
if v, ok := m["root_expiry"].(string); ok {
cfg.RootExpiry = v
}
return nil
}
func marshalPrivateKey(key crypto.PrivateKey) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}
func parseCertPEM(data []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
func parsePrivateKeyPEM(data []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
return x509.ParsePKCS8PrivateKey(block.Bytes)
}
func zeroizeKey(key crypto.PrivateKey) {
if key == nil {
return
}
switch k := key.(type) {
case *ecdsa.PrivateKey:
k.D.SetInt64(0)
case *rsa.PrivateKey:
k.D.SetInt64(0)
for _, p := range k.Primes {
p.SetInt64(0)
}
case ed25519.PrivateKey:
for i := range k {
k[i] = 0
}
}
}
func toStringSlice(v []interface{}) []string {
s := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok {
s = append(s, str)
}
}
return s
}
func ipStrings(ips []net.IP) []string {
s := make([]string, 0, len(ips))
for _, ip := range ips {
s = append(s, ip.String())
}
return s
}
func firstOrEmpty(s []string) string {
if len(s) > 0 {
return s[0]
}
return ""
}