v3.1.0 d676137

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	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}

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