f7f07c71
feat: add CA certificate generation, loading, and leaf cert signing
a73x 2026-03-29 16:18
Commit message
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 |