mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:51:41 +00:00
a8fc177118
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>
186 lines
5.4 KiB
Go
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
|
|
}
|