mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 20:18:55 +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:
@@ -42,6 +42,7 @@ type IssuanceRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
}
|
||||
|
||||
// IssuanceResult contains the result of a successful certificate issuance.
|
||||
@@ -59,6 +60,7 @@ type RenewalRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
OrderID *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -242,8 +242,8 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -467,7 +467,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
|
||||
// generateCertificate creates an X.509 certificate signed by the local CA.
|
||||
// It uses the CSR subject and adds any additional SANs from the request.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) {
|
||||
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string) (*x509.Certificate, string, string, error) {
|
||||
// Generate random serial number
|
||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||
if err != nil {
|
||||
@@ -506,18 +507,18 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve EKUs: use provided list or fall back to default TLS EKUs
|
||||
resolvedEKUs, keyUsage := resolveEKUsAndKeyUsage(ekus)
|
||||
|
||||
// Create certificate template
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
},
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: keyUsage,
|
||||
ExtKeyUsage: resolvedEKUs,
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emails,
|
||||
SubjectKeyId: hashPublicKey(csr.PublicKey),
|
||||
@@ -580,6 +581,67 @@ func isEmail(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
||||
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
||||
"serverAuth": x509.ExtKeyUsageServerAuth,
|
||||
"clientAuth": x509.ExtKeyUsageClientAuth,
|
||||
"codeSigning": x509.ExtKeyUsageCodeSigning,
|
||||
"emailProtection": x509.ExtKeyUsageEmailProtection,
|
||||
"timeStamping": x509.ExtKeyUsageTimeStamping,
|
||||
}
|
||||
|
||||
// resolveEKUsAndKeyUsage maps EKU string names to x509.ExtKeyUsage constants and computes
|
||||
// appropriate KeyUsage flags. If ekus is empty/nil, falls back to default TLS EKUs.
|
||||
//
|
||||
// Key usage selection:
|
||||
// - TLS (serverAuth/clientAuth): DigitalSignature | KeyEncipherment
|
||||
// - S/MIME (emailProtection): DigitalSignature | ContentCommitment (for non-repudiation)
|
||||
// - Mixed: union of both
|
||||
func resolveEKUsAndKeyUsage(ekus []string) ([]x509.ExtKeyUsage, x509.KeyUsage) {
|
||||
if len(ekus) == 0 {
|
||||
// Default: TLS server + client
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
var resolved []x509.ExtKeyUsage
|
||||
hasEmail := false
|
||||
hasTLS := false
|
||||
|
||||
for _, name := range ekus {
|
||||
if eku, ok := ekuNameToX509[name]; ok {
|
||||
resolved = append(resolved, eku)
|
||||
if name == "emailProtection" {
|
||||
hasEmail = true
|
||||
}
|
||||
if name == "serverAuth" || name == "clientAuth" {
|
||||
hasTLS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid EKUs were resolved, fall back to default
|
||||
if len(resolved) == 0 {
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
// Compute KeyUsage based on EKU mix
|
||||
keyUsage := x509.KeyUsageDigitalSignature
|
||||
if hasTLS {
|
||||
keyUsage |= x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
if hasEmail {
|
||||
keyUsage |= x509.KeyUsageContentCommitment // non-repudiation for S/MIME
|
||||
}
|
||||
|
||||
return resolved, keyUsage
|
||||
}
|
||||
|
||||
// hashPublicKey generates a subject key identifier from a public key.
|
||||
func hashPublicKey(pub interface{}) []byte {
|
||||
h := sha256.New()
|
||||
|
||||
Reference in New Issue
Block a user