v2.31.0-dev21 b59533b

ca.go RawHistoryFolder

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
452// InstallRoot installs the root CA certificate into the system trust store.
453func (ca *CA) InstallRoot(cert *x509.Certificate) error {
454	return InstallRoot(cert)
455}
456
457// UninstallRoot removes the root CA certificate from the system trust store.
458func (ca *CA) UninstallRoot(cert *x509.Certificate) error {
459	return UninstallRoot(cert)
460}
461
462func randomSerial() (*big.Int, error) {
463	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
464	if err != nil {
465		return nil, fmt.Errorf("generate serial: %w", err)
466	}
467	return serial, nil
468}
469
470func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) {
471	block, _ := pem.Decode(certPEM)
472	if block == nil {
473		return nil, nil, fmt.Errorf("failed to decode certificate PEM")
474	}
475	cert, err := x509.ParseCertificate(block.Bytes)
476	if err != nil {
477		return nil, nil, fmt.Errorf("parse certificate: %w", err)
478	}
479
480	keyBlock, _ := pem.Decode(keyPEM)
481	if keyBlock == nil {
482		return nil, nil, fmt.Errorf("failed to decode key PEM")
483	}
484	key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
485	if err != nil {
486		return nil, nil, fmt.Errorf("parse private key (only ECDSA P-256 is supported): %w", err)
487	}
488
489	return cert, key, nil
490}
491
492// validateDomain performs defense-in-depth validation of a domain name
493// before issuing a certificate.
494func validateDomain(domain string) error {
495	if err := validation.ValidateDomainParam(domain); err != nil {
496		return err
497	}
498	if len(domain) > 253 {
499		return fmt.Errorf("domain too long (%d chars), limit is 253", len(domain))
500	}
501	if strings.HasPrefix(domain, "*") {
502		return fmt.Errorf("wildcard domains not allowed")
503	}
504	if net.ParseIP(domain) != nil {
505		return fmt.Errorf("IP addresses not allowed, must be a domain name")
506	}
507	return nil
508}
509
510func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) {
511	dir := filepath.Dir(path)
512	if err := os.MkdirAll(dir, 0700); err != nil {
513		return fmt.Errorf("create dir for %s: %w", path, err)
514	}
515	// Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions.
516	tmp, err := os.CreateTemp(dir, ".tmp-*")
517	if err != nil {
518		return fmt.Errorf("create temp file for %s: %w", path, err)
519	}
520	tmpName := tmp.Name()
521	defer func() {
522		if retErr != nil {
523			os.Remove(tmpName)
524		}
525	}()
526	if _, err := tmp.Write(data); err != nil {
527		tmp.Close()
528		return fmt.Errorf("write temp file for %s: %w", path, err)
529	}
530	if err := tmp.Close(); err != nil {
531		return fmt.Errorf("close temp file for %s: %w", path, err)
532	}
533	if err := os.Chmod(tmpName, perm); err != nil {
534		return fmt.Errorf("chmod temp file for %s: %w", path, err)
535	}
536	if err := os.Rename(tmpName, path); err != nil {
537		return fmt.Errorf("rename temp file to %s: %w", path, err)
538	}
539	return nil
540}
541
542// verifyKeyPair checks that a certificate's public key matches the private key.
543func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error {
544	certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
545	if !ok {
546		return fmt.Errorf("certificate has non-ECDSA public key")
547	}
548	keyPub, ok := key.Public().(*ecdsa.PublicKey)
549	if !ok {
550		return fmt.Errorf("signer has non-ECDSA public key")
551	}
552	if !certPub.Equal(keyPub) {
553		return fmt.Errorf("public keys do not match")
554	}
555	return nil
556}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close