mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 16:58:53 +00:00
feat(m27): certificate export (PEM/PKCS#12) and S/MIME EKU support
Add certificate export in PEM (JSON or file download) and PKCS#12 formats. Private keys are never included — they stay on agents. Add EKU-aware issuance threading profile EKUs (serverAuth, clientAuth, codeSigning, emailProtection, timeStamping) through the full issuance pipeline. Fix agent CSR SAN splitting for email addresses, adaptive KeyUsage flags for S/MIME vs TLS, and a pre-existing generateID collision bug in deployment job creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// ExportService provides certificate export functionality (PEM and PKCS#12).
|
||||
type ExportService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
auditService *AuditService
|
||||
}
|
||||
|
||||
// NewExportService creates a new export service.
|
||||
func NewExportService(
|
||||
certRepo repository.CertificateRepository,
|
||||
auditService *AuditService,
|
||||
) *ExportService {
|
||||
return &ExportService{
|
||||
certRepo: certRepo,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportPEMResult contains the PEM-encoded certificate chain.
|
||||
type ExportPEMResult struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
ChainPEM string `json:"chain_pem"`
|
||||
FullPEM string `json:"full_pem"` // cert + chain concatenated
|
||||
}
|
||||
|
||||
// ExportPEM returns the PEM-encoded certificate and chain for the latest version.
|
||||
func (s *ExportService) ExportPEM(ctx context.Context, certID string) (*ExportPEMResult, error) {
|
||||
// Verify certificate exists
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Get latest version (contains the PEM chain)
|
||||
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no certificate version found: %w", err)
|
||||
}
|
||||
|
||||
// Split PEM chain into leaf cert + chain
|
||||
certPEM, chainPEM := splitPEMChain(version.PEMChain)
|
||||
|
||||
// Audit the export
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
||||
"export_pem", "certificate", cert.ID,
|
||||
map[string]interface{}{"serial": version.SerialNumber}); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return &ExportPEMResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
FullPEM: version.PEMChain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExportPKCS12 returns a PKCS#12 bundle containing the certificate chain.
|
||||
// The private key is NOT included — it lives on the agent and never touches the control plane.
|
||||
// The PKCS#12 bundle is encrypted with the provided password (can be empty for cert-only bundles).
|
||||
func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) {
|
||||
// Verify certificate exists
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Get latest version
|
||||
version, err := s.certRepo.GetLatestVersion(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no certificate version found: %w", err)
|
||||
}
|
||||
|
||||
// Parse PEM chain into x509.Certificate objects
|
||||
certs, err := parsePEMCertificates(version.PEMChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM chain")
|
||||
}
|
||||
|
||||
// Build PKCS#12 bundle: leaf cert + CA chain (no private key)
|
||||
leaf := certs[0]
|
||||
var caCerts []*x509.Certificate
|
||||
if len(certs) > 1 {
|
||||
caCerts = certs[1:]
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 trust store (cert-only bundle, no private key)
|
||||
pfxData, err := encodePKCS12CertOnly(leaf, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
// Audit the export
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser,
|
||||
"export_pkcs12", "certificate", cert.ID,
|
||||
map[string]interface{}{"serial": version.SerialNumber, "has_private_key": false}); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// encodePKCS12CertOnly creates a PKCS#12 bundle with certificate(s) but no private key.
|
||||
// Uses the go-pkcs12 library's Modern encoder for strong encryption.
|
||||
func encodePKCS12CertOnly(leaf *x509.Certificate, caCerts []*x509.Certificate, password string) ([]byte, error) {
|
||||
// go-pkcs12's Modern.Encode expects a private key; for cert-only bundles we use
|
||||
// EncodeTrustStore which stores certs as trusted entries.
|
||||
// Include the leaf in the trust store alongside CA certs.
|
||||
allCerts := make([]*x509.Certificate, 0, 1+len(caCerts))
|
||||
allCerts = append(allCerts, leaf)
|
||||
allCerts = append(allCerts, caCerts...)
|
||||
return pkcs12.Modern.EncodeTrustStore(allCerts, password)
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (leaf) and remaining chain.
|
||||
func splitPEMChain(fullPEM string) (string, string) {
|
||||
data := []byte(fullPEM)
|
||||
var blocks []*pem.Block
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return fullPEM, ""
|
||||
}
|
||||
|
||||
certPEM := string(pem.EncodeToMemory(blocks[0]))
|
||||
var chainPEM string
|
||||
for i := 1; i < len(blocks); i++ {
|
||||
chainPEM += string(pem.EncodeToMemory(blocks[i]))
|
||||
}
|
||||
|
||||
return certPEM, chainPEM
|
||||
}
|
||||
|
||||
// parsePEMCertificates parses all certificates from a PEM-encoded string.
|
||||
func parsePEMCertificates(pemData string) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
data := []byte(pemData)
|
||||
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
Reference in New Issue
Block a user