Skip to content

Commit d3d7a5f

Browse files
Merge pull request #248 from numtide/feat/generic-cert-module
feat(cert): add generic TLS cert lifecycle module
2 parents 0712f0c + 1f0335d commit d3d7a5f

File tree

7 files changed

+2464
-0
lines changed

7 files changed

+2464
-0
lines changed

pkg/cert/doc.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Package cert provides a generic, consumer-agnostic TLS certificate lifecycle manager
2+
// for Kubernetes operators.
3+
//
4+
// It supports two primary modes of operation:
5+
//
6+
// 1. Self-Signed (Auto-Bootstrap & Rotation):
7+
// This package implements a production-grade Split-Secret PKI architecture.
8+
//
9+
// Architecture:
10+
// - Root CA: Generated once and stored in a Kubernetes Secret (name configured via Options).
11+
// This secret should NOT be mounted to any pod to prevent key compromise.
12+
// - Server Cert: Signed by the Root CA and stored in a separate Secret.
13+
//
14+
// Lifecycle:
15+
// - Bootstrap: On startup, checks if the secrets exist. If not, generates them.
16+
// - Rotation: A background loop checks for expiration hourly. If the server cert
17+
// is expiring (or the CA changes), it automatically renews the secrets.
18+
// - Hooks: After reconciling PKI, an optional PostReconcileHook is called with the
19+
// CA bundle. Consumers use this for tasks like patching webhook configurations.
20+
// - Projection Wait: Optionally waits for the Kubelet to project secret files to disk
21+
// before returning from Bootstrap (useful for projected volume mounts).
22+
// - Observability: Emits standard Kubernetes Events for all rotation actions.
23+
//
24+
// 2. External (e.g., cert-manager):
25+
// In this mode, certificates are provisioned by an external controller and the
26+
// consumer simply points to the correct directory. The cert package is not used.
27+
//
28+
// Usage:
29+
//
30+
// mgr := cert.NewManager(client, recorder, cert.Options{
31+
// Namespace: "my-ns",
32+
// CASecretName: "my-ca-secret",
33+
// ServerSecretName: "my-server-certs",
34+
// ServiceName: "my-service",
35+
// })
36+
// if err := mgr.Bootstrap(ctx); err != nil {
37+
// // handle error
38+
// }
39+
package cert

pkg/cert/generator.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package cert
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/x509"
8+
"crypto/x509/pkix"
9+
"encoding/pem"
10+
"fmt"
11+
"math/big"
12+
"net"
13+
"time"
14+
)
15+
16+
const (
17+
// Organization is the default organization name used in generated certificates.
18+
Organization = "Multigres Operator"
19+
// CAValidityDuration is the duration the CA certificate is valid for (10 years).
20+
CAValidityDuration = 10 * 365 * 24 * time.Hour
21+
// ServerValidityDuration is the duration the server certificate is valid for (1 year).
22+
ServerValidityDuration = 365 * 24 * time.Hour
23+
)
24+
25+
// CAArtifacts holds the Certificate Authority keys and PEM-encoded data.
26+
type CAArtifacts struct {
27+
Cert *x509.Certificate
28+
Key *ecdsa.PrivateKey
29+
CertPEM []byte
30+
KeyPEM []byte
31+
}
32+
33+
// ServerArtifacts holds the server certificate PEM-encoded data.
34+
type ServerArtifacts struct {
35+
CertPEM []byte
36+
KeyPEM []byte
37+
}
38+
39+
// serverCertConfig holds the resolved configuration for server cert generation.
40+
type serverCertConfig struct {
41+
extKeyUsages []x509.ExtKeyUsage
42+
}
43+
44+
// ServerCertOption configures optional behavior for GenerateServerCert.
45+
type ServerCertOption func(*serverCertConfig)
46+
47+
// WithExtKeyUsages overrides the default extended key usage for the generated
48+
// server certificate. The default is [x509.ExtKeyUsageServerAuth].
49+
// Use this when mutual TLS is needed (e.g., pgBackRest requires both
50+
// ServerAuth and ClientAuth).
51+
func WithExtKeyUsages(usages ...x509.ExtKeyUsage) ServerCertOption {
52+
return func(cfg *serverCertConfig) {
53+
cfg.extKeyUsages = usages
54+
}
55+
}
56+
57+
// internal variables for mocking in tests
58+
var (
59+
marshalECPrivateKey = x509.MarshalECPrivateKey
60+
parseCertificate = x509.ParseCertificate
61+
)
62+
63+
// GenerateCA creates a new self-signed Root CA using ECDSA P-256.
64+
func GenerateCA() (*CAArtifacts, error) {
65+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to generate CA private key: %w", err)
68+
}
69+
70+
template := x509.Certificate{
71+
SerialNumber: big.NewInt(1),
72+
Subject: pkix.Name{
73+
CommonName: "Multigres Operator CA",
74+
Organization: []string{Organization},
75+
},
76+
NotBefore: time.Now().Add(-1 * time.Hour),
77+
NotAfter: time.Now().Add(CAValidityDuration),
78+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
79+
ExtKeyUsage: []x509.ExtKeyUsage{
80+
x509.ExtKeyUsageServerAuth,
81+
x509.ExtKeyUsageClientAuth,
82+
},
83+
BasicConstraintsValid: true,
84+
IsCA: true,
85+
}
86+
87+
derBytes, err := x509.CreateCertificate(
88+
rand.Reader,
89+
&template,
90+
&template,
91+
&privKey.PublicKey,
92+
privKey,
93+
)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to create CA certificate: %w", err)
96+
}
97+
98+
caCert, err := parseCertificate(derBytes)
99+
if err != nil {
100+
return nil, fmt.Errorf("failed to parse generated CA: %w", err)
101+
}
102+
103+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
104+
105+
keyBytes, err := marshalECPrivateKey(privKey)
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to marshal CA key: %w", err)
108+
}
109+
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
110+
111+
return &CAArtifacts{
112+
Cert: caCert,
113+
Key: privKey,
114+
CertPEM: certPEM,
115+
KeyPEM: keyPEM,
116+
}, nil
117+
}
118+
119+
// GenerateServerCert creates a leaf certificate signed by the provided CA.
120+
// By default, the certificate includes only x509.ExtKeyUsageServerAuth.
121+
// Use WithExtKeyUsages to override this (e.g., for mutual TLS).
122+
func GenerateServerCert(
123+
ca *CAArtifacts,
124+
commonName string,
125+
dnsNames []string,
126+
opts ...ServerCertOption,
127+
) (*ServerArtifacts, error) {
128+
if ca == nil {
129+
return nil, fmt.Errorf("CA artifacts cannot be nil")
130+
}
131+
132+
cfg := serverCertConfig{
133+
extKeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
134+
}
135+
for _, opt := range opts {
136+
opt(&cfg)
137+
}
138+
139+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to generate server private key: %w", err)
142+
}
143+
144+
// Serial number should be unique. In a real PKI we'd track this,
145+
// but for ephemeral K8s secrets using a large random int is standard practice.
146+
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
147+
serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit)
148+
149+
template := x509.Certificate{
150+
SerialNumber: serialNumber,
151+
Subject: pkix.Name{
152+
CommonName: commonName,
153+
Organization: []string{Organization},
154+
},
155+
DNSNames: dnsNames,
156+
NotBefore: time.Now().Add(-1 * time.Hour),
157+
NotAfter: time.Now().Add(ServerValidityDuration),
158+
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
159+
ExtKeyUsage: cfg.extKeyUsages,
160+
}
161+
162+
if ip := net.ParseIP(commonName); ip != nil {
163+
template.IPAddresses = append(template.IPAddresses, ip)
164+
}
165+
166+
derBytes, err := x509.CreateCertificate(
167+
rand.Reader,
168+
&template,
169+
ca.Cert,
170+
&privKey.PublicKey,
171+
ca.Key,
172+
)
173+
if err != nil {
174+
return nil, fmt.Errorf("failed to sign server certificate: %w", err)
175+
}
176+
177+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
178+
179+
keyBytes, err := marshalECPrivateKey(privKey)
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to marshal server key: %w", err)
182+
}
183+
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
184+
185+
return &ServerArtifacts{
186+
CertPEM: certPEM,
187+
KeyPEM: keyPEM,
188+
}, nil
189+
}
190+
191+
// ParseCA decodes PEM data back into crypto objects for signing usage.
192+
func ParseCA(certPEM, keyPEM []byte) (*CAArtifacts, error) {
193+
// Parse Cert
194+
block, _ := pem.Decode(certPEM)
195+
if block == nil {
196+
return nil, fmt.Errorf("failed to decode CA cert PEM")
197+
}
198+
cert, err := x509.ParseCertificate(block.Bytes)
199+
if err != nil {
200+
return nil, fmt.Errorf("failed to parse CA cert: %w", err)
201+
}
202+
203+
// Parse Key
204+
block, _ = pem.Decode(keyPEM)
205+
if block == nil {
206+
return nil, fmt.Errorf("failed to decode CA key PEM")
207+
}
208+
// We optimistically try EC, then fallback to PKCS8 if needed, strictly P-256 for us.
209+
key, err := x509.ParseECPrivateKey(block.Bytes)
210+
if err != nil {
211+
// Fallback for older keys or PKCS8 wrapping
212+
if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
213+
switch k := k.(type) {
214+
case *ecdsa.PrivateKey:
215+
key = k
216+
default:
217+
return nil, fmt.Errorf("found non-ECDSA private key type in CA secret")
218+
}
219+
} else {
220+
return nil, fmt.Errorf("failed to parse CA private key: %w", err)
221+
}
222+
}
223+
224+
return &CAArtifacts{
225+
Cert: cert,
226+
Key: key,
227+
CertPEM: certPEM,
228+
KeyPEM: keyPEM,
229+
}, nil
230+
}

0 commit comments

Comments
 (0)