a73x

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