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 sum := sha256.Sum256(ca.rootCert.Raw)
97 parts := make([]string, 0, sha256.Size)
98 for _, b := range sum {
99 parts = append(parts, fmt.Sprintf("%02X", b))
100 }
101 return strings.Join(parts, ":")
102}
103
104// RootCommonName returns the CN of the root CA certificate.
105func (ca *CA) RootCommonName() string {
106 return ca.rootCert.Subject.CommonName
107}
108
109// IntermediateExpiresAt returns the intermediate CA certificate expiry.
110func (ca *CA) IntermediateExpiresAt() time.Time {
111 ca.mu.RLock()
112 defer ca.mu.RUnlock()
113 return ca.interCert.NotAfter
114}
115
116// RenewIntermediate regenerates the intermediate CA certificate,
117// signed by the root CA. Existing leaf certs remain valid.
118func (ca *CA) RenewIntermediate() error {
119 ca.renewMu.Lock()
120 defer ca.renewMu.Unlock()
121
122 cert, key, err := ca.generateIntermediate()
123 if err != nil {
124 return err
125 }
126 if err := ca.storeIntermediate(cert, key); err != nil {
127 return err
128 }
129 ca.mu.Lock()
130 ca.interCert = cert
131 ca.interKey = key
132 ca.mu.Unlock()
133 ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed")
134 return nil
135}
136
137func (ca *CA) loadOrGenerateRoot() error {
138 certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt")
139 keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key")
140
141 certPEM, certErr := os.ReadFile(certPath)
142 keyPEM, keyErr := os.ReadFile(keyPath)
143
144 certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
145 keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
146
147 switch {
148 case certErr == nil && keyErr == nil:
149 cert, key, err := parseCertAndKey(certPEM, keyPEM)
150 if err != nil {
151 return fmt.Errorf("corrupt root CA files: %w", err)
152 }
153 if err := verifyKeyPair(cert, key); err != nil {
154 return fmt.Errorf("root CA cert/key mismatch: %w", err)
155 }
156 ca.rootCert = cert
157 ca.rootKey = key
158 ca.rootPEM = certPEM
159 ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
160 return nil
161
162 case certMissing && keyMissing:
163 return ca.bootstrapRoot(certPath, keyPath)
164
165 case certMissing || keyMissing:
166 return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing)
167
168 default:
169 if certErr != nil {
170 return fmt.Errorf("read root cert: %w", certErr)
171 }
172 return fmt.Errorf("read root key: %w", keyErr)
173 }
174}
175
176func (ca *CA) bootstrapRoot(certPath, keyPath string) error {
177 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
178 if err != nil {
179 return fmt.Errorf("generate root key: %w", err)
180 }
181
182 serial, err := randomSerial()
183 if err != nil {
184 return err
185 }
186
187 now := time.Now()
188 template := &x509.Certificate{
189 SerialNumber: serial,
190 Subject: pkix.Name{
191 CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()),
192 Organization: []string{"Gordon"},
193 },
194 NotBefore: now.Add(-5 * time.Minute),
195 NotAfter: now.Add(rootLifetime),
196 KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
197 BasicConstraintsValid: true,
198 IsCA: true,
199 MaxPathLen: 1,
200 }
201
202 certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
203 if err != nil {
204 return fmt.Errorf("create root cert: %w", err)
205 }
206
207 cert, err := x509.ParseCertificate(certDER)
208 if err != nil {
209 return fmt.Errorf("parse root cert: %w", err)
210 }
211
212 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
213 keyDER, err := x509.MarshalECPrivateKey(key)
214 if err != nil {
215 return fmt.Errorf("marshal root key: %w", err)
216 }
217 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
218
219 // Write key first — orphaned key is harmless; missing cert triggers regeneration.
220 if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
221 return err
222 }
223 if err := writeSecure(certPath, certPEM, 0644); err != nil {
224 return err
225 }
226
227 ca.rootCert = cert
228 ca.rootKey = key
229 ca.rootPEM = certPEM
230 ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA")
231 return nil
232}
233
234// validateLoadedIntermediate parses, verifies, and checks the expiry of a loaded
235// intermediate CA cert/key pair. Returns (cert, key, true) on success, or
236// (nil, nil, false) if the pair should be regenerated (parse error, key mismatch,
237// wrong signer, or expired). Logging is done inside this function.
238func (ca *CA) validateLoadedIntermediate(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, bool) {
239 cert, key, err := parseCertAndKey(certPEM, keyPEM)
240 if err != nil {
241 ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
242 return nil, nil, false
243 }
244 if err := verifyKeyPair(cert, key); err != nil {
245 ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating")
246 return nil, nil, false
247 }
248 if err := cert.CheckSignatureFrom(ca.rootCert); err != nil {
249 ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating")
250 return nil, nil, false
251 }
252 if !time.Now().Before(cert.NotAfter) {
253 ca.log.Warn().Msg("intermediate CA expired, regenerating")
254 return nil, nil, false
255 }
256 return cert, key, true
257}
258
259func (ca *CA) loadOrGenerateIntermediate() error {
260 certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
261 keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
262
263 certPEM, certErr := os.ReadFile(certPath)
264 keyPEM, keyErr := os.ReadFile(keyPath)
265
266 certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
267 keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
268
269 switch {
270 case certErr == nil && keyErr == nil:
271 if cert, key, ok := ca.validateLoadedIntermediate(certPEM, keyPEM); ok {
272 ca.interCert = cert
273 ca.interKey = key
274 ca.log.Info().
275 Str("cn", cert.Subject.CommonName).
276 Time("expires", cert.NotAfter).
277 Msg("loaded existing intermediate CA")
278 return nil
279 }
280
281 case certMissing && keyMissing:
282 // Both files absent — generate fresh intermediate below.
283
284 case certMissing || keyMissing:
285 ca.log.Warn().
286 Bool("cert_missing", certMissing).
287 Bool("key_missing", keyMissing).
288 Msg("intermediate CA files incomplete, regenerating")
289
290 default:
291 // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO).
292 if certErr != nil {
293 return fmt.Errorf("read intermediate cert: %w", certErr)
294 }
295 return fmt.Errorf("read intermediate key: %w", keyErr)
296 }
297
298 cert, key, err := ca.generateIntermediate()
299 if err != nil {
300 return err
301 }
302 if err := ca.storeIntermediate(cert, key); err != nil {
303 return err
304 }
305 ca.interCert = cert
306 ca.interKey = key
307 return nil
308}
309
310func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) {
311 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
312 if err != nil {
313 return nil, nil, fmt.Errorf("generate intermediate key: %w", err)
314 }
315
316 serial, err := randomSerial()
317 if err != nil {
318 return nil, nil, err
319 }
320
321 now := time.Now()
322 template := &x509.Certificate{
323 SerialNumber: serial,
324 Subject: pkix.Name{
325 CommonName: "Gordon Internal CA - ECC Intermediate",
326 Organization: []string{"Gordon"},
327 },
328 NotBefore: now.Add(-5 * time.Minute),
329 NotAfter: now.Add(intermediateLifetime),
330 KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
331 BasicConstraintsValid: true,
332 IsCA: true,
333 MaxPathLen: 0,
334 MaxPathLenZero: true,
335 }
336
337 certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey)
338 if err != nil {
339 return nil, nil, fmt.Errorf("create intermediate cert: %w", err)
340 }
341
342 cert, err := x509.ParseCertificate(certDER)
343 if err != nil {
344 return nil, nil, fmt.Errorf("parse intermediate cert: %w", err)
345 }
346
347 ca.log.Info().
348 Str("cn", cert.Subject.CommonName).
349 Time("expires", cert.NotAfter).
350 Msg("generated new intermediate CA")
351 return cert, key, nil
352}
353
354func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error {
355 certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
356 keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
357
358 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
359 keyDER, err := x509.MarshalECPrivateKey(key)
360 if err != nil {
361 return fmt.Errorf("marshal intermediate key: %w", err)
362 }
363 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
364
365 // Write key first — if cert write fails afterward, the orphaned key
366 // is harmless: loadOrGenerateIntermediate requires both files and
367 // regenerates when either is missing.
368 if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
369 return err
370 }
371 return writeSecure(certPath, certPEM, 0644)
372}
373
374// IssueCertificate generates a leaf cert for the given domain,
375// signed by the current intermediate CA.
376func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
377 if err := validateDomain(domain); err != nil {
378 return nil, fmt.Errorf("invalid domain: %w", err)
379 }
380
381 ca.mu.RLock()
382 interCert := ca.interCert
383 interKey := ca.interKey
384 ca.mu.RUnlock()
385
386 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
387 if err != nil {
388 return nil, fmt.Errorf("generate leaf key: %w", err)
389 }
390
391 serial, err := randomSerial()
392 if err != nil {
393 return nil, err
394 }
395
396 now := time.Now()
397 expiry := now.Add(leafLifetime)
398 if expiry.After(interCert.NotAfter) {
399 expiry = interCert.NotAfter.Add(-1 * time.Minute)
400 if !expiry.After(now) {
401 return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required")
402 }
403 }
404
405 template := &x509.Certificate{
406 SerialNumber: serial,
407 Subject: pkix.Name{
408 CommonName: domain,
409 },
410 DNSNames: []string{domain},
411 NotBefore: now.Add(-5 * time.Minute),
412 NotAfter: expiry,
413 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
414 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
415 }
416
417 certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey)
418 if err != nil {
419 return nil, fmt.Errorf("create leaf cert: %w", err)
420 }
421
422 leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
423 interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw})
424 chainPEM := append(leafPEM, interPEM...)
425
426 keyDER, err := x509.MarshalECPrivateKey(key)
427 if err != nil {
428 return nil, fmt.Errorf("marshal leaf key: %w", err)
429 }
430 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
431
432 tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
433 clear(keyDER)
434 clear(keyPEM)
435 if err != nil {
436 return nil, fmt.Errorf("create TLS cert: %w", err)
437 }
438
439 return &tlsCert, nil
440}
441
442// LeafLifetime returns the configured leaf certificate lifetime.
443func (ca *CA) LeafLifetime() time.Duration {
444 return leafLifetime
445}
446
447// IntermediateLifetime returns the configured intermediate CA lifetime.
448func (ca *CA) IntermediateLifetime() time.Duration {
449 return intermediateLifetime
450}
451
452func randomSerial() (*big.Int, error) {
453 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
454 if err != nil {
455 return nil, fmt.Errorf("generate serial: %w", err)
456 }
457 return serial, nil
458}
459
460func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) {
461 block, _ := pem.Decode(certPEM)
462 if block == nil {
463 return nil, nil, fmt.Errorf("failed to decode certificate PEM")
464 }
465 cert, err := x509.ParseCertificate(block.Bytes)
466 if err != nil {
467 return nil, nil, fmt.Errorf("parse certificate: %w", err)
468 }
469
470 keyBlock, _ := pem.Decode(keyPEM)
471 if keyBlock == nil {
472 return nil, nil, fmt.Errorf("failed to decode key PEM")
473 }
474 key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
475 if err != nil {
476 return nil, nil, fmt.Errorf("parse private key (only ECDSA P-256 is supported): %w", err)
477 }
478
479 return cert, key, nil
480}
481
482// validateDomain performs defense-in-depth validation of a domain name
483// before issuing a certificate.
484func validateDomain(domain string) error {
485 if err := validation.ValidateDomainParam(domain); err != nil {
486 return err
487 }
488 if len(domain) > 253 {
489 return fmt.Errorf("domain too long (%d chars), limit is 253", len(domain))
490 }
491 if strings.HasPrefix(domain, "*") {
492 return fmt.Errorf("wildcard domains not allowed")
493 }
494 if net.ParseIP(domain) != nil {
495 return fmt.Errorf("IP addresses not allowed, must be a domain name")
496 }
497 return nil
498}
499
500func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) {
501 dir := filepath.Dir(path)
502 if err := os.MkdirAll(dir, 0700); err != nil {
503 return fmt.Errorf("create dir for %s: %w", path, err)
504 }
505 // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions.
506 tmp, err := os.CreateTemp(dir, ".tmp-*")
507 if err != nil {
508 return fmt.Errorf("create temp file for %s: %w", path, err)
509 }
510 tmpName := tmp.Name()
511 defer func() {
512 if retErr != nil {
513 os.Remove(tmpName)
514 }
515 }()
516 if _, err := tmp.Write(data); err != nil {
517 tmp.Close()
518 return fmt.Errorf("write temp file for %s: %w", path, err)
519 }
520 if err := tmp.Close(); err != nil {
521 return fmt.Errorf("close temp file for %s: %w", path, err)
522 }
523 if err := os.Chmod(tmpName, perm); err != nil {
524 return fmt.Errorf("chmod temp file for %s: %w", path, err)
525 }
526 if err := os.Rename(tmpName, path); err != nil {
527 return fmt.Errorf("rename temp file to %s: %w", path, err)
528 }
529 return nil
530}
531
532// verifyKeyPair checks that a certificate's public key matches the private key.
533func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error {
534 certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
535 if !ok {
536 return fmt.Errorf("certificate has non-ECDSA public key")
537 }
538 keyPub, ok := key.Public().(*ecdsa.PublicKey)
539 if !ok {
540 return fmt.Errorf("signer has non-ECDSA public key")
541 }
542 if !certPub.Equal(keyPub) {
543 return fmt.Errorf("public keys do not match")
544 }
545 return nil
546}