- Fix #61: handleRotateKey and handleDeleteUser now zeroize stored privBytes instead of calling Bytes() (which returns a copy). New state populates privBytes; old references nil'd for GC. - Add audit logging subsystem (internal/audit) with structured event recording for cryptographic operations. - Add audit log engine spec (engines/auditlog.md). - Add ValidateName checks across all engines for path traversal (#48). - Update AUDIT.md: all High findings resolved (0 open). - Add REMEDIATION.md with detailed remediation tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1589 lines
41 KiB
Go
1589 lines
41 KiB
Go
// Package ca implements the CA (PKI) engine for X.509 certificate issuance.
|
|
package ca
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"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")
|
|
ErrIdentifierInUse = errors.New("ca: identifier already issued to another user")
|
|
)
|
|
|
|
// 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 {
|
|
barrier barrier.Barrier
|
|
rootKey crypto.PrivateKey
|
|
config *CAConfig
|
|
rootCert *x509.Certificate
|
|
issuers map[string]*issuerState
|
|
mountPath string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// 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 "sign-csr":
|
|
return e.handleSignCSR(ctx, req)
|
|
case "import-root":
|
|
return e.handleImportRoot(ctx, req)
|
|
case "revoke-cert":
|
|
return e.handleRevokeCert(ctx, req)
|
|
case "delete-cert":
|
|
return e.handleDeleteCert(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
|
|
}
|
|
|
|
// GetCRLDER generates and returns a DER-encoded CRL for the named issuer,
|
|
// covering all revoked leaf certificates signed by that issuer.
|
|
func (e *CAEngine) GetCRLDER(ctx context.Context, 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
|
|
}
|
|
|
|
// Scan all cert records for revoked certs issued by this issuer.
|
|
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ca: list certs for CRL: %w", err)
|
|
}
|
|
|
|
var entries []x509.RevocationListEntry
|
|
for _, p := range paths {
|
|
if !strings.HasSuffix(p, ".json") {
|
|
continue
|
|
}
|
|
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var record CertRecord
|
|
if err := json.Unmarshal(data, &record); err != nil {
|
|
continue
|
|
}
|
|
if !record.Revoked || record.Issuer != issuerName {
|
|
continue
|
|
}
|
|
serial := new(big.Int)
|
|
serial.SetString(record.Serial, 16)
|
|
entries = append(entries, x509.RevocationListEntry{
|
|
SerialNumber: serial,
|
|
RevocationTime: record.RevokedAt,
|
|
})
|
|
}
|
|
|
|
template := &x509.RevocationList{
|
|
Number: big.NewInt(time.Now().Unix()),
|
|
ThisUpdate: time.Now(),
|
|
NextUpdate: time.Now().Add(24 * time.Hour),
|
|
RevokedCertificateEntries: entries,
|
|
}
|
|
|
|
signer, ok2 := is.key.(crypto.Signer)
|
|
if !ok2 {
|
|
return nil, fmt.Errorf("ca: issuer key does not implement crypto.Signer")
|
|
}
|
|
return x509.CreateRevocationList(rand.Reader, template, is.cert, signer)
|
|
}
|
|
|
|
// mountName extracts the user-facing mount name from the mount path.
|
|
// Mount paths are "engine/{type}/{name}/".
|
|
func (e *CAEngine) mountName() string {
|
|
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
|
|
if len(parts) >= 3 {
|
|
return parts[2]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authorizeIssuance checks whether the caller may issue a cert with the given
|
|
// identifiers (CN + SANs). For each identifier:
|
|
// 1. If a policy rule explicitly allows or denies it, that decision wins.
|
|
// 2. If no policy covers it, the default ownership rule applies: the identifier
|
|
// must not be held by an active cert issued by another user.
|
|
//
|
|
// Caller must hold e.mu (at least RLock).
|
|
func (e *CAEngine) authorizeIssuance(ctx context.Context, req *engine.Request, cn string, sans []string) error {
|
|
mountName := e.mountName()
|
|
|
|
var needOwnershipCheck []string
|
|
for _, id := range append([]string{cn}, sans...) {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if req.CheckPolicy != nil {
|
|
resource := "ca/" + mountName + "/id/" + id
|
|
effect, matched := req.CheckPolicy(resource, "write")
|
|
if matched {
|
|
if effect == "deny" {
|
|
return ErrForbidden
|
|
}
|
|
continue // policy explicitly allows
|
|
}
|
|
}
|
|
needOwnershipCheck = append(needOwnershipCheck, id)
|
|
}
|
|
|
|
if len(needOwnershipCheck) == 0 {
|
|
return nil
|
|
}
|
|
return e.checkIdentifierOwnership(ctx, needOwnershipCheck, req.CallerInfo.Username)
|
|
}
|
|
|
|
// checkIdentifierOwnership scans all active (non-revoked, non-expired) cert
|
|
// records and returns ErrIdentifierInUse if any of the given identifiers are
|
|
// held by a cert issued by a different user.
|
|
//
|
|
// Caller must hold e.mu (at least RLock).
|
|
func (e *CAEngine) checkIdentifierOwnership(ctx context.Context, identifiers []string, username string) error {
|
|
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
|
|
if err != nil {
|
|
return fmt.Errorf("ca: list certs for ownership check: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, p := range paths {
|
|
if !strings.HasSuffix(p, ".json") {
|
|
continue
|
|
}
|
|
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var record CertRecord
|
|
if err := json.Unmarshal(data, &record); err != nil {
|
|
continue
|
|
}
|
|
if record.Revoked || now.After(record.ExpiresAt) {
|
|
continue
|
|
}
|
|
if strings.EqualFold(record.IssuedBy, username) {
|
|
continue
|
|
}
|
|
|
|
// Check for overlap with the requested identifiers.
|
|
held := make(map[string]bool)
|
|
held[strings.ToLower(record.CN)] = true
|
|
for _, san := range record.SANs {
|
|
held[strings.ToLower(san)] = true
|
|
}
|
|
for _, id := range identifiers {
|
|
if held[strings.ToLower(id)] {
|
|
return fmt.Errorf("%w: %s", ErrIdentifierInUse, id)
|
|
}
|
|
}
|
|
}
|
|
return 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.Format(time.RFC3339),
|
|
},
|
|
}, 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
|
|
}
|
|
if err := engine.ValidateName(issuerName); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
}
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, fmt.Errorf("ca: %w", err)
|
|
}
|
|
|
|
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 := "26280h" // 3 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,
|
|
}
|
|
|
|
e.setProfileAIA(&profile)
|
|
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
|
|
}
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
|
|
if e.rootCert == nil {
|
|
return nil, ErrSealed
|
|
}
|
|
|
|
names := make([]interface{}, 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
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
issuerName, _ := req.Data["issuer"].(string)
|
|
if issuerName == "" {
|
|
issuerName = req.Path
|
|
}
|
|
if issuerName == "" {
|
|
return nil, fmt.Errorf("ca: issuer name is required")
|
|
}
|
|
if err := engine.ValidateName(issuerName); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if e.rootCert == nil {
|
|
return nil, ErrSealed
|
|
}
|
|
|
|
is, ok := e.issuers[issuerName]
|
|
if !ok {
|
|
return nil, ErrIssuerNotFound
|
|
}
|
|
|
|
// Authorization: admins bypass all issuance checks.
|
|
if !req.CallerInfo.IsAdmin {
|
|
sans := append(dnsNames, ipAddrs...)
|
|
if err := e.authorizeIssuance(ctx, req, cn, sans); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
profile, ok := GetProfile(profileName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
|
|
}
|
|
|
|
// Validate and apply TTL against issuer MaxTTL.
|
|
requestedTTL, _ := req.Data["ttl"].(string)
|
|
ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profile.Expiry = ttl.String()
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
e.setProfileAIA(&profile)
|
|
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.Format(time.RFC3339),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
|
if req.CallerInfo == nil {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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.Format(time.RFC3339),
|
|
"expires_at": record.ExpiresAt.Format(time.RFC3339),
|
|
"revoked": record.Revoked,
|
|
}
|
|
if record.Revoked {
|
|
data["revoked_at"] = record.RevokedAt.Format(time.RFC3339)
|
|
data["revoked_by"] = record.RevokedBy
|
|
}
|
|
return &engine.Response{Data: data}, nil
|
|
}
|
|
|
|
func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
|
if req.CallerInfo == nil {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
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 []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.Format(time.RFC3339),
|
|
"expires_at": record.ExpiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Authorization: admins bypass; otherwise check policy then ownership.
|
|
if !req.CallerInfo.IsAdmin {
|
|
allowed := false
|
|
if req.CheckPolicy != nil {
|
|
resource := "ca/" + e.mountName() + "/id/" + serial
|
|
effect, matched := req.CheckPolicy(resource, "write")
|
|
if matched {
|
|
if effect == "deny" {
|
|
return nil, ErrForbidden
|
|
}
|
|
allowed = true
|
|
}
|
|
}
|
|
if !allowed && !strings.EqualFold(record.IssuedBy, req.CallerInfo.Username) {
|
|
return nil, ErrForbidden
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
e.setProfileAIA(&profile)
|
|
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.Format(time.RFC3339),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
|
if req.CallerInfo == nil {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if !req.CallerInfo.IsUser() {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
issuerName, _ := req.Data["issuer"].(string)
|
|
if issuerName == "" {
|
|
issuerName = req.Path
|
|
}
|
|
if issuerName == "" {
|
|
return nil, fmt.Errorf("ca: issuer name is required")
|
|
}
|
|
if err := engine.ValidateName(issuerName); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
csrPEM, _ := req.Data["csr_pem"].(string)
|
|
if csrPEM == "" {
|
|
return nil, fmt.Errorf("ca: csr_pem is required")
|
|
}
|
|
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
|
return nil, fmt.Errorf("ca: invalid CSR PEM")
|
|
}
|
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ca: parse CSR: %w", err)
|
|
}
|
|
if err := csr.CheckSignature(); err != nil {
|
|
return nil, fmt.Errorf("ca: invalid CSR signature: %w", err)
|
|
}
|
|
|
|
profileName, _ := req.Data["profile"].(string)
|
|
if profileName == "" {
|
|
profileName = "server"
|
|
}
|
|
profile, ok := GetProfile(profileName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
|
|
}
|
|
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if e.rootCert == nil {
|
|
return nil, ErrSealed
|
|
}
|
|
|
|
is, ok := e.issuers[issuerName]
|
|
if !ok {
|
|
return nil, ErrIssuerNotFound
|
|
}
|
|
|
|
// Validate and apply TTL against issuer MaxTTL.
|
|
requestedTTL, _ := req.Data["ttl"].(string)
|
|
ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profile.Expiry = ttl.String()
|
|
|
|
// Authorization: admins bypass; otherwise check identifiers from the CSR.
|
|
if !req.CallerInfo.IsAdmin {
|
|
sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...)
|
|
if err := e.authorizeIssuance(ctx, req, csr.Subject.CommonName, sans); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
e.setProfileAIA(&profile)
|
|
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ca: sign CSR: %w", err)
|
|
}
|
|
|
|
leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw})
|
|
|
|
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)
|
|
cn := csr.Subject.CommonName
|
|
allSANs := append(leafCert.DNSNames, ipStrings(leafCert.IPAddresses)...)
|
|
|
|
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),
|
|
"chain_pem": string(chainPEM),
|
|
"cn": cn,
|
|
"sans": allSANs,
|
|
"issued_by": req.CallerInfo.Username,
|
|
"expires_at": leafCert.NotAfter.Format(time.RFC3339),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (e *CAEngine) handleRevokeCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
|
if req.CallerInfo == nil {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if !req.CallerInfo.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
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()
|
|
|
|
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)
|
|
}
|
|
|
|
if record.Revoked {
|
|
return nil, fmt.Errorf("ca: certificate is already revoked")
|
|
}
|
|
|
|
record.Revoked = true
|
|
record.RevokedAt = time.Now()
|
|
record.RevokedBy = req.CallerInfo.Username
|
|
|
|
updated, 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", updated); err != nil {
|
|
return nil, fmt.Errorf("ca: store cert record: %w", err)
|
|
}
|
|
|
|
return &engine.Response{
|
|
Data: map[string]interface{}{
|
|
"serial": serial,
|
|
"revoked_at": record.RevokedAt.Format(time.RFC3339),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (e *CAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
|
if req.CallerInfo == nil {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
if !req.CallerInfo.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
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()
|
|
|
|
// Verify the record exists before deleting.
|
|
_, 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)
|
|
}
|
|
|
|
if err := e.barrier.Delete(ctx, e.mountPath+"certs/"+serial+".json"); err != nil {
|
|
return nil, fmt.Errorf("ca: delete cert record: %w", err)
|
|
}
|
|
|
|
return &engine.Response{
|
|
Data: map[string]interface{}{"ok": true},
|
|
}, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// setProfileAIA populates the AIA (Authority Information Access) extension
|
|
// URLs on the profile if external_url is configured. This allows clients
|
|
// to discover the issuing CA certificate for chain building.
|
|
func (e *CAEngine) setProfileAIA(profile *certgen.Profile) {
|
|
if e.config.ExternalURL == "" {
|
|
return
|
|
}
|
|
base := strings.TrimSuffix(e.config.ExternalURL, "/")
|
|
mount := e.mountName()
|
|
profile.IssuingCertificateURL = []string{
|
|
base + "/v1/pki/" + mount + "/ca/chain",
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if v, ok := m["external_url"].(string); ok {
|
|
cfg.ExternalURL = 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveTTL parses and validates a requested TTL against the issuer's MaxTTL.
|
|
func resolveTTL(requested, issuerMaxTTL string) (time.Duration, error) {
|
|
maxTTL, err := time.ParseDuration(issuerMaxTTL)
|
|
if err != nil || maxTTL <= 0 {
|
|
maxTTL = 2160 * time.Hour // 90 days fallback
|
|
}
|
|
if requested != "" {
|
|
ttl, err := time.ParseDuration(requested)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("ca: invalid TTL %q: %w", requested, err)
|
|
}
|
|
if ttl > maxTTL {
|
|
return 0, fmt.Errorf("ca: requested TTL %s exceeds issuer maximum %s", ttl, maxTTL)
|
|
}
|
|
return ttl, nil
|
|
}
|
|
return maxTTL, nil
|
|
}
|
|
|
|
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 ""
|
|
}
|