1// Package pki implements the certificate authority adapter for internal PKI operations.
2package pki
3
4import (
5 "crypto"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/sha256"
10 "crypto/tls"
11 "crypto/x509"
12 "crypto/x509/pkix"
13 "encoding/pem"
14 "errors"
15 "fmt"
16 "math/big"
17 "net"
18 "os"
19 "path/filepath"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/bnema/zerowrap"
25
26 out "github.com/bnema/gordon/internal/boundaries/out"
27 "github.com/bnema/gordon/pkg/validation"
28)
29
30// Compile-time check: *CA satisfies the CertificateAuthority boundary.
31var _ out.CertificateAuthority = (*CA)(nil)
32
33// Certificate lifetimes balance security and operational convenience:
34// - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap.
35// - Intermediate: short-lived to limit blast radius if compromised; auto-renewed.
36// - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory.
37const (
38 rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years
39 intermediateLifetime = 7 * 24 * time.Hour // 7 days
40 leafLifetime = 12 * time.Hour // 12 hours
41
42 pkiDir = "pki"
43)
44
45// CA implements the CertificateAuthority boundary interface.
46type CA struct {
47 dataDir string
48 log zerowrap.Logger
49
50 rootCert *x509.Certificate
51 rootKey crypto.Signer
52 rootPEM []byte
53
54 // mu guards interCert/interKey for concurrent reads during cert issuance.
55 // renewMu serialises intermediate CA renewal.
56 // Lock ordering: renewMu → mu (never acquire mu before renewMu).
57 mu sync.RWMutex
58 interCert *x509.Certificate
59 interKey crypto.Signer
60
61 renewMu sync.Mutex
62}
63
64// NewCA loads or generates the root and intermediate CA certificates.
65func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) {
66 ca := &CA{
67 dataDir: dataDir,
68 log: log,
69 }
70
71 dir := filepath.Join(dataDir, pkiDir)
72 if err := os.MkdirAll(dir, 0700); err != nil {
73 return nil, fmt.Errorf("create pki dir: %w", err)
74 }
75
76 if err := ca.loadOrGenerateRoot(); err != nil {
77 return nil, fmt.Errorf("root CA: %w", err)
78 }
79
80 if err := ca.loadOrGenerateIntermediate(); err != nil {
81 return nil, fmt.Errorf("intermediate CA: %w", err)
82 }
83
84 return ca, nil
85}
86
87// RootCertificate returns the root CA certificate in PEM format.
88func (ca *CA) RootCertificate() []byte { return ca.rootPEM }
89
90// RootCertificateDER returns the root CA certificate in DER format.
91func (ca *CA) RootCertificateDER() []byte { return ca.rootCert.Raw }
92
93// RootFingerprint returns the SHA-256 fingerprint of the root CA cert
94// formatted as colon-separated hex.
95func (ca *CA) RootFingerprint() string {
96 return CertFingerprint(ca.rootCert)
97}
98
99// CertFingerprint returns the SHA-256 fingerprint of cert formatted as
100// colon-separated uppercase hex (the format used by RootFingerprint).
101func CertFingerprint(cert *x509.Certificate) string {
102 sum := sha256.Sum256(cert.Raw)
103 parts := make([]string, 0, sha256.Size)
104 for _, b := range sum {
105 parts = append(parts, fmt.Sprintf("%02X", b))
106 }
107 return strings.Join(parts, ":")
108}
109
110// RootCommonName returns the CN of the root CA certificate.
111func (ca *CA) RootCommonName() string {
112 return ca.rootCert.Subject.CommonName
113}
114
115// IntermediateExpiresAt returns the intermediate CA certificate expiry.
116func (ca *CA) IntermediateExpiresAt() time.Time {
117 ca.mu.RLock()
118 defer ca.mu.RUnlock()
119 return ca.interCert.NotAfter
120}
121
122// RenewIntermediate regenerates the intermediate CA certificate,
123// signed by the root CA. Existing leaf certs remain valid.
124func (ca *CA) RenewIntermediate() error {
125 ca.renewMu.Lock()
126 defer ca.renewMu.Unlock()
127
128 cert, key, err := ca.generateIntermediate()
129 if err != nil {
130 return err
131 }
132 if err := ca.storeIntermediate(cert, key); err != nil {
133 return err
134 }
135 ca.mu.Lock()
136 ca.interCert = cert
137 ca.interKey = key
138 ca.mu.Unlock()
139 ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed")
140 return nil
141}
142
143func (ca *CA) loadOrGenerateRoot() error {
144 certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt")
145 keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key")
146
147 certPEM, certErr := os.ReadFile(certPath)
148 keyPEM, keyErr := os.ReadFile(keyPath)
149
150 certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
151 keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
152
153 switch {
154 case certErr == nil && keyErr == nil:
155 cert, key, err := parseCertAndKey(certPEM, keyPEM)
156 if err != nil {
157 return fmt.Errorf("corrupt root CA files: %w", err)
158 }
159 if err := verifyKeyPair(cert, key); err != nil {
160 return fmt.Errorf("root CA cert/key mismatch: %w", err)
161 }
162 ca.rootCert = cert
163 ca.rootKey = key
164 ca.rootPEM = certPEM
165 ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
166 return nil
167
168 case certMissing && keyMissing:
169 return ca.bootstrapRoot(certPath, keyPath)
170
171 case certMissing || keyMissing:
172 return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing)
173
174 default:
175 if certErr != nil {
176 return fmt.Errorf("read root cert: %w", certErr)
177 }
178 return fmt.Errorf("read root key: %w", keyErr)
179 }
180}
181
182func (ca *CA) bootstrapRoot(certPath, keyPath string) error {
183 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
184 if err != nil {
185 return fmt.Errorf("generate root key: %w", err)
186 }
187
188 serial, err := randomSerial()
189 if err != nil {
190 return err
191 }
192
193 now := time.Now()
194 template := &x509.Certificate{
195 SerialNumber: serial,
196 Subject: pkix.Name{
197 CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()),
198 Organization: []string{"Gordon"},
199 },
200 NotBefore: now.Add(-5 * time.Minute),
201 NotAfter: now.Add(rootLifetime),
202 KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
203 BasicConstraintsValid: true,
204 IsCA: true,
205 MaxPathLen: 1,
206 }
207
208 certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
209 if err != nil {
210 return fmt.Errorf("create root cert: %w", err)
211 }
212
213 cert, err := x509.ParseCertificate(certDER)
214 if err != nil {
215 return fmt.Errorf("parse root cert: %w", err)
216 }
217
218 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
219 keyDER, err := x509.MarshalECPrivateKey(key)
220 if err != nil {
221 return fmt.Errorf("marshal root key: %w", err)
222 }
223 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
224
225 // Write key first — orphaned key is harmless; missing cert triggers regeneration.
226 if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
227 return err
228 }
229 if err := writeSecure(certPath, certPEM, 0644); err != nil {
230 return err
231 }
232
233 ca.rootCert = cert
234 ca.rootKey = key
235 ca.rootPEM = certPEM
236 ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA")
237 return nil
238}
239
240// validateLoadedIntermediate parses, verifies, and checks the expiry of a loaded
241// intermediate CA cert/key pair. Returns (cert, key, true) on success, or
242// (nil, nil, false) if the pair should be regenerated (parse error, key mismatch,
243// wrong signer, or expired). Logging is done inside this function.
244func (ca *CA) validateLoadedIntermediate(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, bool) {
245 cert, key, err := parseCertAndKey(certPEM, keyPEM)
246 if err != nil {
247 ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
248 return nil, nil, false
249 }
250 if err := verifyKeyPair(cert, key); err != nil {
251 ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating")
252 return nil, nil, false
253 }
254 if err := cert.CheckSignatureFrom(ca.rootCert); err != nil {
255 ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating")
256 return nil, nil, false
257 }
258 if !time.Now().Before(cert.NotAfter) {
259 ca.log.Warn().Msg("intermediate CA expired, regenerating")
260 return nil, nil, false
261 }
262 return cert, key, true
263}
264
265func (ca *CA) loadOrGenerateIntermediate() error {
266 certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
267 keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
268
269 certPEM, certErr := os.ReadFile(certPath)
270 keyPEM, keyErr := os.ReadFile(keyPath)
271
272 certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
273 keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
274
275 switch {
276 case certErr == nil && keyErr == nil:
277 if cert, key, ok := ca.validateLoadedIntermediate(certPEM, keyPEM); ok {
278 ca.interCert = cert
279 ca.interKey = key
280 ca.log.Info().
281 Str("cn", cert.Subject.CommonName).
282 Time("expires", cert.NotAfter).
283 Msg("loaded existing intermediate CA")
284 return nil
285 }
286
287 case certMissing && keyMissing:
288 // Both files absent — generate fresh intermediate below.
289
290 case certMissing || keyMissing:
291 ca.log.Warn().
292 Bool("cert_missing", certMissing).
293 Bool("key_missing", keyMissing).
294 Msg("intermediate CA files incomplete, regenerating")
295
296 default:
297 // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO).
298 if certErr != nil {
299 return fmt.Errorf("read intermediate cert: %w", certErr)
300 }
301 return fmt.Errorf("read intermediate key: %w", keyErr)
302 }
303
304 cert, key, err := ca.generateIntermediate()
305 if err != nil {
306 return err
307 }
308 if err := ca.storeIntermediate(cert, key); err != nil {
309 return err
310 }
311 ca.interCert = cert
312 ca.interKey = key
313 return nil
314}
315
316func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) {
317 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
318 if err != nil {
319 return nil, nil, fmt.Errorf("generate intermediate key: %w", err)
320 }
321
322 serial, err := randomSerial()
323 if err != nil {
324 return nil, nil, err
325 }
326
327 now := time.Now()
328 template := &x509.Certificate{
329 SerialNumber: serial,
330 Subject: pkix.Name{
331 CommonName: "Gordon Internal CA - ECC Intermediate",
332 Organization: []string{"Gordon"},
333 },
334 NotBefore: now.Add(-5 * time.Minute),
335 NotAfter: now.Add(intermediateLifetime),
336 KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
337 BasicConstraintsValid: true,
338 IsCA: true,
339 MaxPathLen: 0,
340 MaxPathLenZero: true,
341 }
342
343 certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey)
344 if err != nil {
345 return nil, nil, fmt.Errorf("create intermediate cert: %w", err)
346 }
347
348 cert, err := x509.ParseCertificate(certDER)
349 if err != nil {
350 return nil, nil, fmt.Errorf("parse intermediate cert: %w", err)
351 }
352
353 ca.log.Info().
354 Str("cn", cert.Subject.CommonName).
355 Time("expires", cert.NotAfter).
356 Msg("generated new intermediate CA")
357 return cert, key, nil
358}
359
360func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error {
361 certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
362 keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
363
364 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
365 keyDER, err := x509.MarshalECPrivateKey(key)
366 if err != nil {
367 return fmt.Errorf("marshal intermediate key: %w", err)
368 }
369 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
370
371 // Write key first — if cert write fails afterward, the orphaned key
372 // is harmless: loadOrGenerateIntermediate requires both files and
373 // regenerates when either is missing.
374 if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
375 return err
376 }
377 return writeSecure(certPath, certPEM, 0644)
378}
379
380// IssueCertificate generates a leaf cert for the given domain,
381// signed by the current intermediate CA.
382func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
383 if err := validateDomain(domain); err != nil {
384 return nil, fmt.Errorf("invalid domain: %w", err)
385 }
386
387 ca.mu.RLock()
388 interCert := ca.interCert
389 interKey := ca.interKey
390 ca.mu.RUnlock()
391
392 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
393 if err != nil {
394 return nil, fmt.Errorf("generate leaf key: %w", err)
395 }
396
397 serial, err := randomSerial()
398 if err != nil {
399 return nil, err
400 }
401
402 now := time.Now()
403 expiry := now.Add(leafLifetime)
404 if expiry.After(interCert.NotAfter) {
405 expiry = interCert.NotAfter.Add(-1 * time.Minute)
406 if !expiry.After(now) {
407 return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required")
408 }
409 }
410
411 template := &x509.Certificate{
412 SerialNumber: serial,
413 Subject: pkix.Name{
414 CommonName: domain,
415 },
416 DNSNames: []string{domain},
417 NotBefore: now.Add(-5 * time.Minute),
418 NotAfter: expiry,
419 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
420 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
421 }
422
423 certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey)
424 if err != nil {
425 return nil, fmt.Errorf("create leaf cert: %w", err)
426 }
427
428 leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
429 interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw})
430 chainPEM := append(leafPEM, interPEM...)
431
432 keyDER, err := x509.MarshalECPrivateKey(key)
433 if err != nil {
434 return nil, fmt.Errorf("marshal leaf key: %w", err)
435 }
436 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
437
438 tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
439 clear(keyDER)
440 clear(keyPEM)
441 if err != nil {
442 return nil, fmt.Errorf("create TLS cert: %w", err)
443 }
444
445 return &tlsCert, nil
446}
447
448// LeafLifetime returns the configured leaf certificate lifetime.
449func (ca *CA) LeafLifetime() time.Duration {
450 return leafLifetime
451}
452
453// IntermediateLifetime returns the configured intermediate CA lifetime.
454func (ca *CA) IntermediateLifetime() time.Duration {
455 return intermediateLifetime
456}
457
458// InstallRoot installs the root CA certificate into the system trust store.
459func (ca *CA) InstallRoot(cert *x509.Certificate) error {
460 return InstallRoot(cert)
461}
462
463// UninstallRoot removes the root CA certificate from the system trust store.
464func (ca *CA) UninstallRoot(cert *x509.Certificate) error {
465 return UninstallRoot(cert)
466}
467
468func randomSerial() (*big.Int, error) {
469 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
470 if err != nil {
471 return nil, fmt.Errorf("generate serial: %w", err)
472 }
473 return serial, nil
474}
475
476func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) {
477 block, _ := pem.Decode(certPEM)
478 if block == nil {
479 return nil, nil, fmt.Errorf("failed to decode certificate PEM")
480 }
481 cert, err := x509.ParseCertificate(block.Bytes)
482 if err != nil {
483 return nil, nil, fmt.Errorf("parse certificate: %w", err)
484 }
485
486 keyBlock, _ := pem.Decode(keyPEM)
487 if keyBlock == nil {
488 return nil, nil, fmt.Errorf("failed to decode key PEM")
489 }
490 key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
491 if err != nil {
492 return nil, nil, fmt.Errorf("parse private key (only ECDSA P-256 is supported): %w", err)
493 }
494
495 return cert, key, nil
496}
497
498// validateDomain performs defense-in-depth validation of a domain name
499// before issuing a certificate.
500func validateDomain(domain string) error {
501 if err := validation.ValidateDomainParam(domain); err != nil {
502 return err
503 }
504 if len(domain) > 253 {
505 return fmt.Errorf("domain too long (%d chars), limit is 253", len(domain))
506 }
507 if strings.HasPrefix(domain, "*") {
508 return fmt.Errorf("wildcard domains not allowed")
509 }
510 if net.ParseIP(domain) != nil {
511 return fmt.Errorf("IP addresses not allowed, must be a domain name")
512 }
513 return nil
514}
515
516func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) {
517 dir := filepath.Dir(path)
518 if err := os.MkdirAll(dir, 0700); err != nil {
519 return fmt.Errorf("create dir for %s: %w", path, err)
520 }
521 // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions.
522 tmp, err := os.CreateTemp(dir, ".tmp-*")
523 if err != nil {
524 return fmt.Errorf("create temp file for %s: %w", path, err)
525 }
526 tmpName := tmp.Name()
527 defer func() {
528 if retErr != nil {
529 os.Remove(tmpName)
530 }
531 }()
532 if _, err := tmp.Write(data); err != nil {
533 tmp.Close()
534 return fmt.Errorf("write temp file for %s: %w", path, err)
535 }
536 if err := tmp.Close(); err != nil {
537 return fmt.Errorf("close temp file for %s: %w", path, err)
538 }
539 if err := os.Chmod(tmpName, perm); err != nil {
540 return fmt.Errorf("chmod temp file for %s: %w", path, err)
541 }
542 if err := os.Rename(tmpName, path); err != nil {
543 return fmt.Errorf("rename temp file to %s: %w", path, err)
544 }
545 return nil
546}
547
548// verifyKeyPair checks that a certificate's public key matches the private key.
549func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error {
550 certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
551 if !ok {
552 return fmt.Errorf("certificate has non-ECDSA public key")
553 }
554 keyPub, ok := key.Public().(*ecdsa.PublicKey)
555 if !ok {
556 return fmt.Errorf("signer has non-ECDSA public key")
557 }
558 if !certPub.Equal(keyPub) {
559 return fmt.Errorf("public keys do not match")
560 }
561 return nil
562}