Files
certctl/internal/service/export.go
T
shankar0123 a8fc177118 fix: resolve NULL csr_pem scan errors and QA smoke test failures
Root cause: certificate_versions.csr_pem is nullable in the schema but
Go code scanned it into a plain string. Used sql.NullString in
ListVersions and GetLatestVersion to handle NULL values correctly.

Also includes: partial update fetch-merge-update pattern to prevent FK
violations, nil directory guard in discovery service, diagnostic slog
logging in handlers, export handler 422 for unparseable PEM, OpenAPI
spec corrections, MCP tool description improvements, and test fixes.

Rewrites the Release Sign-Off section in testing-guide.md to individual
test-level granularity (320 rows) with smoke test results audited and
checked off (121 pass, 5 skip, 194 manual remaining).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 00:51:18 -04:00

186 lines
5.4 KiB
Go

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("certificate data cannot be parsed as X.509: %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
}