f7f07c71
feat: add CA certificate generation, loading, and leaf cert signing
a73x 2026-03-29 16:18
diff --git a/ca/ca.go b/ca/ca.go new file mode 100644 index 0000000..97ab7a7 --- /dev/null +++ b/ca/ca.go @@ -0,0 +1,164 @@ package ca import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "math/big" "os" "path/filepath" "time" ) // LoadOrCreate loads an existing CA cert and key from dir, or generates a new one and saves it. func LoadOrCreate(dir string) (*x509.Certificate, *ecdsa.PrivateKey, error) { certPath := filepath.Join(dir, "ca.pem") keyPath := filepath.Join(dir, "ca.key") _, certErr := os.Stat(certPath) _, keyErr := os.Stat(keyPath) if certErr == nil && keyErr == nil { return load(certPath, keyPath) } return generate(certPath, keyPath) } func load(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) { certPEM, err := os.ReadFile(certPath) if err != nil { return nil, nil, err } keyPEM, err := os.ReadFile(keyPath) if err != nil { return nil, nil, err } block, _ := pem.Decode(certPEM) if block == nil { return nil, nil, errors.New("failed to decode ca.pem") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, nil, err } block, _ = pem.Decode(keyPEM) if block == nil { return nil, nil, errors.New("failed to decode ca.key") } key, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { return nil, nil, err } return cert, key, nil } func generate(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, nil, err } template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "Nono Proxy CA", }, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, } certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { return nil, nil, err } cert, err := x509.ParseCertificate(certDER) if err != nil { return nil, nil, err } // Save cert certFile, err := os.Create(certPath) if err != nil { return nil, nil, err } defer certFile.Close() if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { return nil, nil, err } // Save key keyDER, err := x509.MarshalECPrivateKey(key) if err != nil { return nil, nil, err } keyFile, err := os.Create(keyPath) if err != nil { return nil, nil, err } defer keyFile.Close() if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { return nil, nil, err } return cert, key, nil } // GenerateLeaf generates a leaf TLS certificate for the given host, signed by the CA. func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) (tls.Certificate, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return tls.Certificate{}, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, err } template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: host, }, DNSNames: []string{host}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{ x509.ExtKeyUsageServerAuth, }, } certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) if err != nil { return tls.Certificate{}, err } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) keyDER, err := x509.MarshalECPrivateKey(key) if err != nil { return tls.Certificate{}, err } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) return tls.X509KeyPair(certPEM, keyPEM) } diff --git a/ca/ca_test.go b/ca/ca_test.go new file mode 100644 index 0000000..1d9c0d3 --- /dev/null +++ b/ca/ca_test.go @@ -0,0 +1,75 @@ package ca_test import ( "crypto/x509" "os" "path/filepath" "testing" "github.com/xanderle/nono/ca" ) func TestLoadOrCreateCA_GeneratesNewCA(t *testing.T) { dir := t.TempDir() caCert, caKey, err := ca.LoadOrCreate(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } if caCert == nil { t.Fatal("expected CA cert, got nil") } if caKey == nil { t.Fatal("expected CA key, got nil") } if !caCert.IsCA { t.Error("expected cert to be a CA") } if caCert.Subject.CommonName != "Nono Proxy CA" { t.Errorf("expected CN 'Nono Proxy CA', got %q", caCert.Subject.CommonName) } if _, err := os.Stat(filepath.Join(dir, "ca.pem")); err != nil { t.Errorf("ca.pem not written: %v", err) } if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil { t.Errorf("ca.key not written: %v", err) } } func TestLoadOrCreateCA_LoadsExistingCA(t *testing.T) { dir := t.TempDir() cert1, _, err := ca.LoadOrCreate(dir) if err != nil { t.Fatalf("generate: %v", err) } cert2, _, err := ca.LoadOrCreate(dir) if err != nil { t.Fatalf("load: %v", err) } if !cert1.Equal(cert2) { t.Error("expected same cert on reload") } } func TestGenerateLeafCert(t *testing.T) { dir := t.TempDir() caCert, caKey, err := ca.LoadOrCreate(dir) if err != nil { t.Fatalf("CA setup: %v", err) } tlsCert, err := ca.GenerateLeaf("example.com", caCert, caKey) if err != nil { t.Fatalf("unexpected error: %v", err) } leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) if err != nil { t.Fatalf("parse leaf: %v", err) } if leaf.Subject.CommonName != "example.com" { t.Errorf("expected CN 'example.com', got %q", leaf.Subject.CommonName) } pool := x509.NewCertPool() pool.AddCert(caCert) if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil { t.Errorf("leaf cert not signed by CA: %v", err) } } diff --git a/go.mod b/go.mod index 2bfa58b..79a248e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module nono module github.com/xanderle/nono go 1.26.1