a73x

f7f07c71

feat: add CA certificate generation, loading, and leaf cert signing

a73x   2026-03-29 16:18

Commit message
feat: add CA certificate generation, loading, and leaf cert signing

ca/ca.go
Old New
@@ -0,0 +1,164 @@
1 package ca
2
3 import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/tls"
8 "crypto/x509"
9 "crypto/x509/pkix"
10 "encoding/pem"
11 "errors"
12 "math/big"
13 "os"
14 "path/filepath"
15 "time"
16 )
17
18 // LoadOrCreate loads an existing CA cert and key from dir, or generates a new one and saves it.
19 func LoadOrCreate(dir string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
20 certPath := filepath.Join(dir, "ca.pem")
21 keyPath := filepath.Join(dir, "ca.key")
22
23 _, certErr := os.Stat(certPath)
24 _, keyErr := os.Stat(keyPath)
25
26 if certErr == nil && keyErr == nil {
27 return load(certPath, keyPath)
28 }
29
30 return generate(certPath, keyPath)
31 }
32
33 func load(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
34 certPEM, err := os.ReadFile(certPath)
35 if err != nil {
36 return nil, nil, err
37 }
38 keyPEM, err := os.ReadFile(keyPath)
39 if err != nil {
40 return nil, nil, err
41 }
42
43 block, _ := pem.Decode(certPEM)
44 if block == nil {
45 return nil, nil, errors.New("failed to decode ca.pem")
46 }
47 cert, err := x509.ParseCertificate(block.Bytes)
48 if err != nil {
49 return nil, nil, err
50 }
51
52 block, _ = pem.Decode(keyPEM)
53 if block == nil {
54 return nil, nil, errors.New("failed to decode ca.key")
55 }
56 key, err := x509.ParseECPrivateKey(block.Bytes)
57 if err != nil {
58 return nil, nil, err
59 }
60
61 return cert, key, nil
62 }
63
64 func generate(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
65 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
66 if err != nil {
67 return nil, nil, err
68 }
69
70 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
71 if err != nil {
72 return nil, nil, err
73 }
74
75 template := &x509.Certificate{
76 SerialNumber: serial,
77 Subject: pkix.Name{
78 CommonName: "Nono Proxy CA",
79 },
80 NotBefore: time.Now().Add(-time.Minute),
81 NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
82 IsCA: true,
83 BasicConstraintsValid: true,
84 KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
85 }
86
87 certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
88 if err != nil {
89 return nil, nil, err
90 }
91
92 cert, err := x509.ParseCertificate(certDER)
93 if err != nil {
94 return nil, nil, err
95 }
96
97 // Save cert
98 certFile, err := os.Create(certPath)
99 if err != nil {
100 return nil, nil, err
101 }
102 defer certFile.Close()
103 if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
104 return nil, nil, err
105 }
106
107 // Save key
108 keyDER, err := x509.MarshalECPrivateKey(key)
109 if err != nil {
110 return nil, nil, err
111 }
112 keyFile, err := os.Create(keyPath)
113 if err != nil {
114 return nil, nil, err
115 }
116 defer keyFile.Close()
117 if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
118 return nil, nil, err
119 }
120
121 return cert, key, nil
122 }
123
124 // GenerateLeaf generates a leaf TLS certificate for the given host, signed by the CA.
125 func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) (tls.Certificate, error) {
126 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
127 if err != nil {
128 return tls.Certificate{}, err
129 }
130
131 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
132 if err != nil {
133 return tls.Certificate{}, err
134 }
135
136 template := &x509.Certificate{
137 SerialNumber: serial,
138 Subject: pkix.Name{
139 CommonName: host,
140 },
141 DNSNames: []string{host},
142 NotBefore: time.Now().Add(-time.Minute),
143 NotAfter: time.Now().Add(24 * time.Hour),
144 KeyUsage: x509.KeyUsageDigitalSignature,
145 ExtKeyUsage: []x509.ExtKeyUsage{
146 x509.ExtKeyUsageServerAuth,
147 },
148 }
149
150 certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
151 if err != nil {
152 return tls.Certificate{}, err
153 }
154
155 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
156
157 keyDER, err := x509.MarshalECPrivateKey(key)
158 if err != nil {
159 return tls.Certificate{}, err
160 }
161 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
162
163 return tls.X509KeyPair(certPEM, keyPEM)
164 }
ca/ca_test.go
Old New
@@ -0,0 +1,75 @@
1 package ca_test
2
3 import (
4 "crypto/x509"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/xanderle/nono/ca"
10 )
11
12 func TestLoadOrCreateCA_GeneratesNewCA(t *testing.T) {
13 dir := t.TempDir()
14 caCert, caKey, err := ca.LoadOrCreate(dir)
15 if err != nil {
16 t.Fatalf("unexpected error: %v", err)
17 }
18 if caCert == nil {
19 t.Fatal("expected CA cert, got nil")
20 }
21 if caKey == nil {
22 t.Fatal("expected CA key, got nil")
23 }
24 if !caCert.IsCA {
25 t.Error("expected cert to be a CA")
26 }
27 if caCert.Subject.CommonName != "Nono Proxy CA" {
28 t.Errorf("expected CN 'Nono Proxy CA', got %q", caCert.Subject.CommonName)
29 }
30 if _, err := os.Stat(filepath.Join(dir, "ca.pem")); err != nil {
31 t.Errorf("ca.pem not written: %v", err)
32 }
33 if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil {
34 t.Errorf("ca.key not written: %v", err)
35 }
36 }
37
38 func TestLoadOrCreateCA_LoadsExistingCA(t *testing.T) {
39 dir := t.TempDir()
40 cert1, _, err := ca.LoadOrCreate(dir)
41 if err != nil {
42 t.Fatalf("generate: %v", err)
43 }
44 cert2, _, err := ca.LoadOrCreate(dir)
45 if err != nil {
46 t.Fatalf("load: %v", err)
47 }
48 if !cert1.Equal(cert2) {
49 t.Error("expected same cert on reload")
50 }
51 }
52
53 func TestGenerateLeafCert(t *testing.T) {
54 dir := t.TempDir()
55 caCert, caKey, err := ca.LoadOrCreate(dir)
56 if err != nil {
57 t.Fatalf("CA setup: %v", err)
58 }
59 tlsCert, err := ca.GenerateLeaf("example.com", caCert, caKey)
60 if err != nil {
61 t.Fatalf("unexpected error: %v", err)
62 }
63 leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
64 if err != nil {
65 t.Fatalf("parse leaf: %v", err)
66 }
67 if leaf.Subject.CommonName != "example.com" {
68 t.Errorf("expected CN 'example.com', got %q", leaf.Subject.CommonName)
69 }
70 pool := x509.NewCertPool()
71 pool.AddCert(caCert)
72 if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil {
73 t.Errorf("leaf cert not signed by CA: %v", err)
74 }
75 }
go.mod
Old New
@@ -1,3 +1,3 @@
1 module nono 1 module github.com/xanderle/nono
2 2
3 go 1.26.1 3 go 1.26.1