detached b939cf1

feat(pki): implement CA adapter with root, intermediate, and leaf issuance

commit
b939cf11a6b11e0063dde2e33e0d5e91a63fc64a
author
brice · · 2026-03-29 16:34 UTC
committer
brice · · 2026-03-29 16:40 UTC
parent
2242016
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +524 -0
A internal/adapters/out/pki/ca.go +402 -0
@@ -0,0 +1,402 @@
1+// Package pki implements the certificate authority adapter for internal PKI operations.
2+package pki
3+
4+import (
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+ "fmt"
15+ "math/big"
16+ "os"
17+ "path/filepath"
18+ "strings"
19+ "sync"
20+ "time"
21+
22+ "github.com/bnema/zerowrap"
23+
24+ out "github.com/bnema/gordon/internal/boundaries/out"
25+)
26+
27+// Compile-time check: *CA satisfies the CertificateAuthority boundary.
28+var _ out.CertificateAuthority = (*CA)(nil)
29+
30+const (
31+ rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years
32+ intermediateLifetime = 7 * 24 * time.Hour // 7 days
33+ leafLifetime = 12 * time.Hour // 12 hours
34+
35+ pkiDir = "pki"
36+)
37+
38+// CA implements the CertificateAuthority boundary interface.
39+type CA struct {
40+ dataDir string
41+ log zerowrap.Logger
42+ aiaURL string // optional: URL for AIA extension in leaf certs
43+
44+ rootCert *x509.Certificate
45+ rootKey crypto.Signer
46+ rootPEM []byte
47+ rootDER []byte
48+
49+ mu sync.RWMutex
50+ interCert *x509.Certificate
51+ interKey crypto.Signer
52+}
53+
54+// SetAIAURL sets the Authority Information Access URL embedded in leaf certs.
55+// Format: http://{gordon_domain}[:{port}]/ca.crt
56+func (ca *CA) SetAIAURL(url string) { ca.aiaURL = url }
57+
58+// NewCA loads or generates the root and intermediate CA certificates.
59+func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) {
60+ ca := &CA{
61+ dataDir: dataDir,
62+ log: log,
63+ }
64+
65+ dir := filepath.Join(dataDir, pkiDir)
66+ if err := os.MkdirAll(dir, 0700); err != nil {
67+ return nil, fmt.Errorf("create pki dir: %w", err)
68+ }
69+
70+ if err := ca.loadOrGenerateRoot(); err != nil {
71+ return nil, fmt.Errorf("root CA: %w", err)
72+ }
73+
74+ if err := ca.loadOrGenerateIntermediate(); err != nil {
75+ return nil, fmt.Errorf("intermediate CA: %w", err)
76+ }
77+
78+ return ca, nil
79+}
80+
81+// RootCertificate returns the root CA certificate in PEM format.
82+func (ca *CA) RootCertificate() []byte { return ca.rootPEM }
83+
84+// RootCertificateDER returns the root CA certificate in DER format.
85+func (ca *CA) RootCertificateDER() []byte { return ca.rootDER }
86+
87+// RootFingerprint returns the SHA-256 fingerprint of the root CA cert
88+// formatted as colon-separated hex.
89+func (ca *CA) RootFingerprint() string {
90+ sum := sha256.Sum256(ca.rootCert.Raw)
91+ parts := make([]string, len(sum))
92+ for i, b := range sum {
93+ parts[i] = fmt.Sprintf("%02X", b)
94+ }
95+ return strings.Join(parts, ":")
96+}
97+
98+// RootCommonName returns the CN of the root CA certificate.
99+func (ca *CA) RootCommonName() string {
100+ return ca.rootCert.Subject.CommonName
101+}
102+
103+// IntermediateExpiresAt returns the intermediate CA certificate expiry.
104+func (ca *CA) IntermediateExpiresAt() time.Time {
105+ ca.mu.RLock()
106+ defer ca.mu.RUnlock()
107+ return ca.interCert.NotAfter
108+}
109+
110+// RenewIntermediate regenerates the intermediate CA certificate,
111+// signed by the root CA. Existing leaf certs remain valid.
112+func (ca *CA) RenewIntermediate() error {
113+ cert, key, err := ca.generateIntermediate()
114+ if err != nil {
115+ return err
116+ }
117+ if err := ca.storeIntermediate(cert, key); err != nil {
118+ return err
119+ }
120+ ca.mu.Lock()
121+ ca.interCert = cert
122+ ca.interKey = key
123+ ca.mu.Unlock()
124+ ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed")
125+ return nil
126+}
127+
128+func (ca *CA) loadOrGenerateRoot() error {
129+ certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt")
130+ keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key")
131+
132+ certPEM, certErr := os.ReadFile(certPath)
133+ keyPEM, keyErr := os.ReadFile(keyPath)
134+
135+ if certErr == nil && keyErr == nil {
136+ cert, key, err := parseCertAndKey(certPEM, keyPEM)
137+ if err == nil {
138+ ca.rootCert = cert
139+ ca.rootKey = key
140+ ca.rootPEM = certPEM
141+ ca.rootDER = cert.Raw
142+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
143+ return nil
144+ }
145+ ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating")
146+ }
147+
148+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
149+ if err != nil {
150+ return fmt.Errorf("generate root key: %w", err)
151+ }
152+
153+ serial, err := randomSerial()
154+ if err != nil {
155+ return err
156+ }
157+
158+ now := time.Now()
159+ template := &x509.Certificate{
160+ SerialNumber: serial,
161+ Subject: pkix.Name{
162+ CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()),
163+ Organization: []string{"Gordon"},
164+ },
165+ NotBefore: now.Add(-5 * time.Minute),
166+ NotAfter: now.Add(rootLifetime),
167+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
168+ BasicConstraintsValid: true,
169+ IsCA: true,
170+ MaxPathLen: 1,
171+ }
172+
173+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
174+ if err != nil {
175+ return fmt.Errorf("create root cert: %w", err)
176+ }
177+
178+ cert, err := x509.ParseCertificate(certDER)
179+ if err != nil {
180+ return fmt.Errorf("parse root cert: %w", err)
181+ }
182+
183+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
184+ keyDER, err := x509.MarshalECPrivateKey(key)
185+ if err != nil {
186+ return fmt.Errorf("marshal root key: %w", err)
187+ }
188+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
189+
190+ if err := writeSecure(certPath, certPEM, 0644); err != nil {
191+ return err
192+ }
193+ if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
194+ return err
195+ }
196+
197+ ca.rootCert = cert
198+ ca.rootKey = key
199+ ca.rootPEM = certPEM
200+ ca.rootDER = certDER
201+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA")
202+ return nil
203+}
204+
205+func (ca *CA) loadOrGenerateIntermediate() error {
206+ certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
207+ keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
208+
209+ certPEM, certErr := os.ReadFile(certPath)
210+ keyPEM, keyErr := os.ReadFile(keyPath)
211+
212+ if certErr == nil && keyErr == nil {
213+ cert, key, err := parseCertAndKey(certPEM, keyPEM)
214+ if err == nil && time.Now().Before(cert.NotAfter) {
215+ ca.interCert = cert
216+ ca.interKey = key
217+ ca.log.Info().
218+ Str("cn", cert.Subject.CommonName).
219+ Time("expires", cert.NotAfter).
220+ Msg("loaded existing intermediate CA")
221+ return nil
222+ }
223+ if err != nil {
224+ ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
225+ } else {
226+ ca.log.Warn().Msg("intermediate CA expired, regenerating")
227+ }
228+ }
229+
230+ cert, key, err := ca.generateIntermediate()
231+ if err != nil {
232+ return err
233+ }
234+ if err := ca.storeIntermediate(cert, key); err != nil {
235+ return err
236+ }
237+ ca.interCert = cert
238+ ca.interKey = key
239+ return nil
240+}
241+
242+func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) {
243+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
244+ if err != nil {
245+ return nil, nil, fmt.Errorf("generate intermediate key: %w", err)
246+ }
247+
248+ serial, err := randomSerial()
249+ if err != nil {
250+ return nil, nil, err
251+ }
252+
253+ now := time.Now()
254+ template := &x509.Certificate{
255+ SerialNumber: serial,
256+ Subject: pkix.Name{
257+ CommonName: "Gordon Internal CA - ECC Intermediate",
258+ Organization: []string{"Gordon"},
259+ },
260+ NotBefore: now.Add(-5 * time.Minute),
261+ NotAfter: now.Add(intermediateLifetime),
262+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
263+ BasicConstraintsValid: true,
264+ IsCA: true,
265+ MaxPathLen: 0,
266+ MaxPathLenZero: true,
267+ }
268+
269+ certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey)
270+ if err != nil {
271+ return nil, nil, fmt.Errorf("create intermediate cert: %w", err)
272+ }
273+
274+ cert, err := x509.ParseCertificate(certDER)
275+ if err != nil {
276+ return nil, nil, fmt.Errorf("parse intermediate cert: %w", err)
277+ }
278+
279+ ca.log.Info().
280+ Str("cn", cert.Subject.CommonName).
281+ Time("expires", cert.NotAfter).
282+ Msg("generated new intermediate CA")
283+ return cert, key, nil
284+}
285+
286+func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error {
287+ certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
288+ keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
289+
290+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
291+ keyDER, err := x509.MarshalECPrivateKey(key)
292+ if err != nil {
293+ return fmt.Errorf("marshal intermediate key: %w", err)
294+ }
295+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
296+
297+ if err := writeSecure(certPath, certPEM, 0644); err != nil {
298+ return err
299+ }
300+ return writeSecure(keyPath, keyPEM, 0600)
301+}
302+
303+// IssueCertificate generates a leaf cert for the given domain,
304+// signed by the current intermediate CA.
305+func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
306+ ca.mu.RLock()
307+ interCert := ca.interCert
308+ interKey := ca.interKey
309+ ca.mu.RUnlock()
310+
311+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
312+ if err != nil {
313+ return nil, fmt.Errorf("generate leaf key: %w", err)
314+ }
315+
316+ serial, err := randomSerial()
317+ if err != nil {
318+ return nil, err
319+ }
320+
321+ now := time.Now()
322+ template := &x509.Certificate{
323+ SerialNumber: serial,
324+ Subject: pkix.Name{
325+ CommonName: domain,
326+ },
327+ DNSNames: []string{domain},
328+ NotBefore: now.Add(-5 * time.Minute),
329+ NotAfter: now.Add(leafLifetime),
330+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
331+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
332+ }
333+
334+ // AIA extension: URL where clients can fetch the CA cert.
335+ if ca.aiaURL != "" {
336+ template.IssuingCertificateURL = []string{ca.aiaURL}
337+ }
338+
339+ certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey)
340+ if err != nil {
341+ return nil, fmt.Errorf("create leaf cert: %w", err)
342+ }
343+
344+ leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
345+ interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw})
346+ chainPEM := append(leafPEM, interPEM...)
347+
348+ keyDER, err := x509.MarshalECPrivateKey(key)
349+ if err != nil {
350+ return nil, fmt.Errorf("marshal leaf key: %w", err)
351+ }
352+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
353+
354+ tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
355+ if err != nil {
356+ return nil, fmt.Errorf("create TLS cert: %w", err)
357+ }
358+
359+ return &tlsCert, nil
360+}
361+
362+// LeafLifetime returns the configured leaf certificate lifetime.
363+func (ca *CA) LeafLifetime() time.Duration {
364+ return leafLifetime
365+}
366+
367+func randomSerial() (*big.Int, error) {
368+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
369+ if err != nil {
370+ return nil, fmt.Errorf("generate serial: %w", err)
371+ }
372+ return serial, nil
373+}
374+
375+func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) {
376+ block, _ := pem.Decode(certPEM)
377+ if block == nil {
378+ return nil, nil, fmt.Errorf("failed to decode certificate PEM")
379+ }
380+ cert, err := x509.ParseCertificate(block.Bytes)
381+ if err != nil {
382+ return nil, nil, fmt.Errorf("parse certificate: %w", err)
383+ }
384+
385+ keyBlock, _ := pem.Decode(keyPEM)
386+ if keyBlock == nil {
387+ return nil, nil, fmt.Errorf("failed to decode key PEM")
388+ }
389+ key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
390+ if err != nil {
391+ return nil, nil, fmt.Errorf("parse key: %w", err)
392+ }
393+
394+ return cert, key, nil
395+}
396+
397+func writeSecure(path string, data []byte, perm os.FileMode) error {
398+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
399+ return fmt.Errorf("create dir for %s: %w", path, err)
400+ }
401+ return os.WriteFile(path, data, perm)
402+}
@@ -0,0 +1,402 @@
1+// Package pki implements the certificate authority adapter for internal PKI operations.
2+package pki
3+
4+import (
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+ "fmt"
15+ "math/big"
16+ "os"
17+ "path/filepath"
18+ "strings"
19+ "sync"
20+ "time"
21+
22+ "github.com/bnema/zerowrap"
23+
24+ out "github.com/bnema/gordon/internal/boundaries/out"
25+)
26+
27+// Compile-time check: *CA satisfies the CertificateAuthority boundary.
28+var _ out.CertificateAuthority = (*CA)(nil)
29+
30+const (
31+ rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years
32+ intermediateLifetime = 7 * 24 * time.Hour // 7 days
33+ leafLifetime = 12 * time.Hour // 12 hours
34+
35+ pkiDir = "pki"
36+)
37+
38+// CA implements the CertificateAuthority boundary interface.
39+type CA struct {
40+ dataDir string
41+ log zerowrap.Logger
42+ aiaURL string // optional: URL for AIA extension in leaf certs
43+
44+ rootCert *x509.Certificate
45+ rootKey crypto.Signer
46+ rootPEM []byte
47+ rootDER []byte
48+
49+ mu sync.RWMutex
50+ interCert *x509.Certificate
51+ interKey crypto.Signer
52+}
53+
54+// SetAIAURL sets the Authority Information Access URL embedded in leaf certs.
55+// Format: http://{gordon_domain}[:{port}]/ca.crt
56+func (ca *CA) SetAIAURL(url string) { ca.aiaURL = url }
57+
58+// NewCA loads or generates the root and intermediate CA certificates.
59+func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) {
60+ ca := &CA{
61+ dataDir: dataDir,
62+ log: log,
63+ }
64+
65+ dir := filepath.Join(dataDir, pkiDir)
66+ if err := os.MkdirAll(dir, 0700); err != nil {
67+ return nil, fmt.Errorf("create pki dir: %w", err)
68+ }
69+
70+ if err := ca.loadOrGenerateRoot(); err != nil {
71+ return nil, fmt.Errorf("root CA: %w", err)
72+ }
73+
74+ if err := ca.loadOrGenerateIntermediate(); err != nil {
75+ return nil, fmt.Errorf("intermediate CA: %w", err)
76+ }
77+
78+ return ca, nil
79+}
80+
81+// RootCertificate returns the root CA certificate in PEM format.
82+func (ca *CA) RootCertificate() []byte { return ca.rootPEM }
83+
84+// RootCertificateDER returns the root CA certificate in DER format.
85+func (ca *CA) RootCertificateDER() []byte { return ca.rootDER }
86+
87+// RootFingerprint returns the SHA-256 fingerprint of the root CA cert
88+// formatted as colon-separated hex.
89+func (ca *CA) RootFingerprint() string {
90+ sum := sha256.Sum256(ca.rootCert.Raw)
91+ parts := make([]string, len(sum))
92+ for i, b := range sum {
93+ parts[i] = fmt.Sprintf("%02X", b)
94+ }
95+ return strings.Join(parts, ":")
96+}
97+
98+// RootCommonName returns the CN of the root CA certificate.
99+func (ca *CA) RootCommonName() string {
100+ return ca.rootCert.Subject.CommonName
101+}
102+
103+// IntermediateExpiresAt returns the intermediate CA certificate expiry.
104+func (ca *CA) IntermediateExpiresAt() time.Time {
105+ ca.mu.RLock()
106+ defer ca.mu.RUnlock()
107+ return ca.interCert.NotAfter
108+}
109+
110+// RenewIntermediate regenerates the intermediate CA certificate,
111+// signed by the root CA. Existing leaf certs remain valid.
112+func (ca *CA) RenewIntermediate() error {
113+ cert, key, err := ca.generateIntermediate()
114+ if err != nil {
115+ return err
116+ }
117+ if err := ca.storeIntermediate(cert, key); err != nil {
118+ return err
119+ }
120+ ca.mu.Lock()
121+ ca.interCert = cert
122+ ca.interKey = key
123+ ca.mu.Unlock()
124+ ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed")
125+ return nil
126+}
127+
128+func (ca *CA) loadOrGenerateRoot() error {
129+ certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt")
130+ keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key")
131+
132+ certPEM, certErr := os.ReadFile(certPath)
133+ keyPEM, keyErr := os.ReadFile(keyPath)
134+
135+ if certErr == nil && keyErr == nil {
136+ cert, key, err := parseCertAndKey(certPEM, keyPEM)
137+ if err == nil {
138+ ca.rootCert = cert
139+ ca.rootKey = key
140+ ca.rootPEM = certPEM
141+ ca.rootDER = cert.Raw
142+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
143+ return nil
144+ }
145+ ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating")
146+ }
147+
148+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
149+ if err != nil {
150+ return fmt.Errorf("generate root key: %w", err)
151+ }
152+
153+ serial, err := randomSerial()
154+ if err != nil {
155+ return err
156+ }
157+
158+ now := time.Now()
159+ template := &x509.Certificate{
160+ SerialNumber: serial,
161+ Subject: pkix.Name{
162+ CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()),
163+ Organization: []string{"Gordon"},
164+ },
165+ NotBefore: now.Add(-5 * time.Minute),
166+ NotAfter: now.Add(rootLifetime),
167+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
168+ BasicConstraintsValid: true,
169+ IsCA: true,
170+ MaxPathLen: 1,
171+ }
172+
173+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
174+ if err != nil {
175+ return fmt.Errorf("create root cert: %w", err)
176+ }
177+
178+ cert, err := x509.ParseCertificate(certDER)
179+ if err != nil {
180+ return fmt.Errorf("parse root cert: %w", err)
181+ }
182+
183+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
184+ keyDER, err := x509.MarshalECPrivateKey(key)
185+ if err != nil {
186+ return fmt.Errorf("marshal root key: %w", err)
187+ }
188+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
189+
190+ if err := writeSecure(certPath, certPEM, 0644); err != nil {
191+ return err
192+ }
193+ if err := writeSecure(keyPath, keyPEM, 0600); err != nil {
194+ return err
195+ }
196+
197+ ca.rootCert = cert
198+ ca.rootKey = key
199+ ca.rootPEM = certPEM
200+ ca.rootDER = certDER
201+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA")
202+ return nil
203+}
204+
205+func (ca *CA) loadOrGenerateIntermediate() error {
206+ certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
207+ keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
208+
209+ certPEM, certErr := os.ReadFile(certPath)
210+ keyPEM, keyErr := os.ReadFile(keyPath)
211+
212+ if certErr == nil && keyErr == nil {
213+ cert, key, err := parseCertAndKey(certPEM, keyPEM)
214+ if err == nil && time.Now().Before(cert.NotAfter) {
215+ ca.interCert = cert
216+ ca.interKey = key
217+ ca.log.Info().
218+ Str("cn", cert.Subject.CommonName).
219+ Time("expires", cert.NotAfter).
220+ Msg("loaded existing intermediate CA")
221+ return nil
222+ }
223+ if err != nil {
224+ ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
225+ } else {
226+ ca.log.Warn().Msg("intermediate CA expired, regenerating")
227+ }
228+ }
229+
230+ cert, key, err := ca.generateIntermediate()
231+ if err != nil {
232+ return err
233+ }
234+ if err := ca.storeIntermediate(cert, key); err != nil {
235+ return err
236+ }
237+ ca.interCert = cert
238+ ca.interKey = key
239+ return nil
240+}
241+
242+func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) {
243+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
244+ if err != nil {
245+ return nil, nil, fmt.Errorf("generate intermediate key: %w", err)
246+ }
247+
248+ serial, err := randomSerial()
249+ if err != nil {
250+ return nil, nil, err
251+ }
252+
253+ now := time.Now()
254+ template := &x509.Certificate{
255+ SerialNumber: serial,
256+ Subject: pkix.Name{
257+ CommonName: "Gordon Internal CA - ECC Intermediate",
258+ Organization: []string{"Gordon"},
259+ },
260+ NotBefore: now.Add(-5 * time.Minute),
261+ NotAfter: now.Add(intermediateLifetime),
262+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
263+ BasicConstraintsValid: true,
264+ IsCA: true,
265+ MaxPathLen: 0,
266+ MaxPathLenZero: true,
267+ }
268+
269+ certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey)
270+ if err != nil {
271+ return nil, nil, fmt.Errorf("create intermediate cert: %w", err)
272+ }
273+
274+ cert, err := x509.ParseCertificate(certDER)
275+ if err != nil {
276+ return nil, nil, fmt.Errorf("parse intermediate cert: %w", err)
277+ }
278+
279+ ca.log.Info().
280+ Str("cn", cert.Subject.CommonName).
281+ Time("expires", cert.NotAfter).
282+ Msg("generated new intermediate CA")
283+ return cert, key, nil
284+}
285+
286+func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error {
287+ certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt")
288+ keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key")
289+
290+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
291+ keyDER, err := x509.MarshalECPrivateKey(key)
292+ if err != nil {
293+ return fmt.Errorf("marshal intermediate key: %w", err)
294+ }
295+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
296+
297+ if err := writeSecure(certPath, certPEM, 0644); err != nil {
298+ return err
299+ }
300+ return writeSecure(keyPath, keyPEM, 0600)
301+}
302+
303+// IssueCertificate generates a leaf cert for the given domain,
304+// signed by the current intermediate CA.
305+func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
306+ ca.mu.RLock()
307+ interCert := ca.interCert
308+ interKey := ca.interKey
309+ ca.mu.RUnlock()
310+
311+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
312+ if err != nil {
313+ return nil, fmt.Errorf("generate leaf key: %w", err)
314+ }
315+
316+ serial, err := randomSerial()
317+ if err != nil {
318+ return nil, err
319+ }
320+
321+ now := time.Now()
322+ template := &x509.Certificate{
323+ SerialNumber: serial,
324+ Subject: pkix.Name{
325+ CommonName: domain,
326+ },
327+ DNSNames: []string{domain},
328+ NotBefore: now.Add(-5 * time.Minute),
329+ NotAfter: now.Add(leafLifetime),
330+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
331+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
332+ }
333+
334+ // AIA extension: URL where clients can fetch the CA cert.
335+ if ca.aiaURL != "" {
336+ template.IssuingCertificateURL = []string{ca.aiaURL}
337+ }
338+
339+ certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey)
340+ if err != nil {
341+ return nil, fmt.Errorf("create leaf cert: %w", err)
342+ }
343+
344+ leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
345+ interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw})
346+ chainPEM := append(leafPEM, interPEM...)
347+
348+ keyDER, err := x509.MarshalECPrivateKey(key)
349+ if err != nil {
350+ return nil, fmt.Errorf("marshal leaf key: %w", err)
351+ }
352+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
353+
354+ tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
355+ if err != nil {
356+ return nil, fmt.Errorf("create TLS cert: %w", err)
357+ }
358+
359+ return &tlsCert, nil
360+}
361+
362+// LeafLifetime returns the configured leaf certificate lifetime.
363+func (ca *CA) LeafLifetime() time.Duration {
364+ return leafLifetime
365+}
366+
367+func randomSerial() (*big.Int, error) {
368+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
369+ if err != nil {
370+ return nil, fmt.Errorf("generate serial: %w", err)
371+ }
372+ return serial, nil
373+}
374+
375+func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) {
376+ block, _ := pem.Decode(certPEM)
377+ if block == nil {
378+ return nil, nil, fmt.Errorf("failed to decode certificate PEM")
379+ }
380+ cert, err := x509.ParseCertificate(block.Bytes)
381+ if err != nil {
382+ return nil, nil, fmt.Errorf("parse certificate: %w", err)
383+ }
384+
385+ keyBlock, _ := pem.Decode(keyPEM)
386+ if keyBlock == nil {
387+ return nil, nil, fmt.Errorf("failed to decode key PEM")
388+ }
389+ key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
390+ if err != nil {
391+ return nil, nil, fmt.Errorf("parse key: %w", err)
392+ }
393+
394+ return cert, key, nil
395+}
396+
397+func writeSecure(path string, data []byte, perm os.FileMode) error {
398+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
399+ return fmt.Errorf("create dir for %s: %w", path, err)
400+ }
401+ return os.WriteFile(path, data, perm)
402+}
A internal/adapters/out/pki/ca_test.go +122 -0
@@ -0,0 +1,122 @@
1+package pki_test
2+
3+import (
4+ "crypto/x509"
5+ "encoding/pem"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/pki"
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func testLogger() zerowrap.Logger {
18+ return zerowrap.Default()
19+}
20+
21+func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) {
22+ dir := t.TempDir()
23+ ca, err := pki.NewCA(dir, testLogger())
24+ require.NoError(t, err)
25+
26+ // Root cert exists on disk
27+ rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt"))
28+ require.NoError(t, err)
29+ block, _ := pem.Decode(rootPEM)
30+ require.NotNil(t, block)
31+ rootCert, err := x509.ParseCertificate(block.Bytes)
32+ require.NoError(t, err)
33+ assert.True(t, rootCert.IsCA)
34+ assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed
35+
36+ // Intermediate cert exists on disk
37+ interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt"))
38+ require.NoError(t, err)
39+ block, _ = pem.Decode(interPEM)
40+ require.NotNil(t, block)
41+ interCert, err := x509.ParseCertificate(block.Bytes)
42+ require.NoError(t, err)
43+ assert.True(t, interCert.IsCA)
44+ assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root
45+
46+ // Interface methods work
47+ assert.NotEmpty(t, ca.RootCertificate())
48+ assert.NotEmpty(t, ca.RootCertificateDER())
49+ assert.NotEmpty(t, ca.RootFingerprint())
50+ assert.NotEmpty(t, ca.RootCommonName())
51+ assert.False(t, ca.IntermediateExpiresAt().IsZero())
52+}
53+
54+func TestNewCA_LoadsExistingOnRestart(t *testing.T) {
55+ dir := t.TempDir()
56+
57+ // First boot: generate
58+ ca1, err := pki.NewCA(dir, testLogger())
59+ require.NoError(t, err)
60+ fp1 := ca1.RootFingerprint()
61+
62+ // Second boot: load
63+ ca2, err := pki.NewCA(dir, testLogger())
64+ require.NoError(t, err)
65+ fp2 := ca2.RootFingerprint()
66+
67+ assert.Equal(t, fp1, fp2, "root CA should be the same across restarts")
68+}
69+
70+func TestCA_IssueCertificate(t *testing.T) {
71+ dir := t.TempDir()
72+ ca, err := pki.NewCA(dir, testLogger())
73+ require.NoError(t, err)
74+
75+ cert, err := ca.IssueCertificate("test.example.com")
76+ require.NoError(t, err)
77+ require.NotNil(t, cert)
78+
79+ // Parse the leaf cert
80+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
81+ require.NoError(t, err)
82+ assert.Equal(t, "test.example.com", leaf.Subject.CommonName)
83+ assert.Contains(t, leaf.DNSNames, "test.example.com")
84+ assert.False(t, leaf.IsCA)
85+
86+ // Verify chain: leaf -> intermediate -> root
87+ rootPool := x509.NewCertPool()
88+ rootPool.AppendCertsFromPEM(ca.RootCertificate())
89+ interPool := x509.NewCertPool()
90+ inter, err := x509.ParseCertificate(cert.Certificate[1])
91+ require.NoError(t, err)
92+ interPool.AddCert(inter)
93+
94+ _, err = leaf.Verify(x509.VerifyOptions{
95+ Roots: rootPool,
96+ Intermediates: interPool,
97+ })
98+ assert.NoError(t, err, "leaf cert should verify against root CA")
99+}
100+
101+func TestCA_RenewIntermediate(t *testing.T) {
102+ dir := t.TempDir()
103+ ca, err := pki.NewCA(dir, testLogger())
104+ require.NoError(t, err)
105+
106+ oldExpiry := ca.IntermediateExpiresAt()
107+
108+ // x509 timestamps have second precision, so we must cross
109+ // a second boundary to observe a different NotAfter.
110+ time.Sleep(1100 * time.Millisecond)
111+
112+ err = ca.RenewIntermediate()
113+ require.NoError(t, err)
114+
115+ newExpiry := ca.IntermediateExpiresAt()
116+ assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry")
117+
118+ // Leaf certs should still work after renewal
119+ cert, err := ca.IssueCertificate("after-renewal.example.com")
120+ require.NoError(t, err)
121+ require.NotNil(t, cert)
122+}
@@ -0,0 +1,122 @@
1+package pki_test
2+
3+import (
4+ "crypto/x509"
5+ "encoding/pem"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/pki"
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func testLogger() zerowrap.Logger {
18+ return zerowrap.Default()
19+}
20+
21+func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) {
22+ dir := t.TempDir()
23+ ca, err := pki.NewCA(dir, testLogger())
24+ require.NoError(t, err)
25+
26+ // Root cert exists on disk
27+ rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt"))
28+ require.NoError(t, err)
29+ block, _ := pem.Decode(rootPEM)
30+ require.NotNil(t, block)
31+ rootCert, err := x509.ParseCertificate(block.Bytes)
32+ require.NoError(t, err)
33+ assert.True(t, rootCert.IsCA)
34+ assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed
35+
36+ // Intermediate cert exists on disk
37+ interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt"))
38+ require.NoError(t, err)
39+ block, _ = pem.Decode(interPEM)
40+ require.NotNil(t, block)
41+ interCert, err := x509.ParseCertificate(block.Bytes)
42+ require.NoError(t, err)
43+ assert.True(t, interCert.IsCA)
44+ assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root
45+
46+ // Interface methods work
47+ assert.NotEmpty(t, ca.RootCertificate())
48+ assert.NotEmpty(t, ca.RootCertificateDER())
49+ assert.NotEmpty(t, ca.RootFingerprint())
50+ assert.NotEmpty(t, ca.RootCommonName())
51+ assert.False(t, ca.IntermediateExpiresAt().IsZero())
52+}
53+
54+func TestNewCA_LoadsExistingOnRestart(t *testing.T) {
55+ dir := t.TempDir()
56+
57+ // First boot: generate
58+ ca1, err := pki.NewCA(dir, testLogger())
59+ require.NoError(t, err)
60+ fp1 := ca1.RootFingerprint()
61+
62+ // Second boot: load
63+ ca2, err := pki.NewCA(dir, testLogger())
64+ require.NoError(t, err)
65+ fp2 := ca2.RootFingerprint()
66+
67+ assert.Equal(t, fp1, fp2, "root CA should be the same across restarts")
68+}
69+
70+func TestCA_IssueCertificate(t *testing.T) {
71+ dir := t.TempDir()
72+ ca, err := pki.NewCA(dir, testLogger())
73+ require.NoError(t, err)
74+
75+ cert, err := ca.IssueCertificate("test.example.com")
76+ require.NoError(t, err)
77+ require.NotNil(t, cert)
78+
79+ // Parse the leaf cert
80+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
81+ require.NoError(t, err)
82+ assert.Equal(t, "test.example.com", leaf.Subject.CommonName)
83+ assert.Contains(t, leaf.DNSNames, "test.example.com")
84+ assert.False(t, leaf.IsCA)
85+
86+ // Verify chain: leaf -> intermediate -> root
87+ rootPool := x509.NewCertPool()
88+ rootPool.AppendCertsFromPEM(ca.RootCertificate())
89+ interPool := x509.NewCertPool()
90+ inter, err := x509.ParseCertificate(cert.Certificate[1])
91+ require.NoError(t, err)
92+ interPool.AddCert(inter)
93+
94+ _, err = leaf.Verify(x509.VerifyOptions{
95+ Roots: rootPool,
96+ Intermediates: interPool,
97+ })
98+ assert.NoError(t, err, "leaf cert should verify against root CA")
99+}
100+
101+func TestCA_RenewIntermediate(t *testing.T) {
102+ dir := t.TempDir()
103+ ca, err := pki.NewCA(dir, testLogger())
104+ require.NoError(t, err)
105+
106+ oldExpiry := ca.IntermediateExpiresAt()
107+
108+ // x509 timestamps have second precision, so we must cross
109+ // a second boundary to observe a different NotAfter.
110+ time.Sleep(1100 * time.Millisecond)
111+
112+ err = ca.RenewIntermediate()
113+ require.NoError(t, err)
114+
115+ newExpiry := ca.IntermediateExpiresAt()
116+ assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry")
117+
118+ // Leaf certs should still work after renewal
119+ cert, err := ca.IssueCertificate("after-renewal.example.com")
120+ require.NoError(t, err)
121+ require.NotNil(t, cert)
122+}

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