diff --git a/certlib/certgen/testca.go b/certlib/certgen/testca.go new file mode 100644 index 0000000..ce238fd --- /dev/null +++ b/certlib/certgen/testca.go @@ -0,0 +1,213 @@ +package certgen + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "net" + "time" +) + +// TestCA is an in-memory certificate authority for use in tests. It +// provides a root CA certificate and the ability to issue leaf +// certificates for TLS testing with full verification enabled. +type TestCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +// NewTestCA creates a new TestCA with a self-signed P-256 root +// certificate. The CA is valid for 1 hour. +func NewTestCA() (*TestCA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("certgen: generating CA key: %w", err) + } + + serial, err := SerialNumber() + if err != nil { + return nil, fmt.Errorf("certgen: generating serial: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "Test CA", + Organization: []string{"Test"}, + }, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("certgen: creating CA certificate: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, fmt.Errorf("certgen: parsing CA certificate: %w", err) + } + + return &TestCA{cert: cert, key: key}, nil +} + +// Certificate returns the root CA certificate. +func (ca *TestCA) Certificate() *x509.Certificate { + return ca.cert +} + +// CertificatePEM returns the root CA certificate as a PEM-encoded +// byte slice. +func (ca *TestCA) CertificatePEM() []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: ca.cert.Raw, + }) +} + +// CertPool returns a certificate pool containing the root CA +// certificate, suitable for use as a TLS root CA pool. +func (ca *TestCA) CertPool() *x509.CertPool { + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + return pool +} + +// Issue creates a new leaf certificate signed by the CA for the given +// DNS names and IP addresses. It returns the leaf private key and +// certificate. The leaf certificate is valid for 1 hour with key +// usage appropriate for a TLS server. +func (ca *TestCA) Issue(dnsNames []string, ips []net.IP) (crypto.Signer, *x509.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("certgen: generating leaf key: %w", err) + } + + serial, err := SerialNumber() + if err != nil { + return nil, nil, fmt.Errorf("certgen: generating serial: %w", err) + } + + cn := "localhost" + if len(dnsNames) > 0 { + cn = dnsNames[0] + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: cn, + Organization: []string{"Test"}, + }, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + DNSNames: dnsNames, + IPAddresses: ips, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + return nil, nil, fmt.Errorf("certgen: creating leaf certificate: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, nil, fmt.Errorf("certgen: parsing leaf certificate: %w", err) + } + + return key, cert, nil +} + +// IssueServer is a convenience wrapper around Issue for the common +// case of a server certificate for localhost (both DNS and IP). +func (ca *TestCA) IssueServer() (crypto.Signer, *x509.Certificate, error) { + return ca.Issue( + []string{"localhost"}, + []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + ) +} + +// TLSConfig returns a tls.Config (by value) configured with the CA's +// root pool for verification. The caller can set additional fields +// (e.g., Certificates) or modify the returned config safely. +func (ca *TestCA) TLSConfig() tls.Config { + return tls.Config{ + RootCAs: ca.CertPool(), + MinVersion: tls.VersionTLS13, + } +} + +// ServerTLSConfig returns a tls.Config (by value) for a TLS server +// using the given leaf key and certificate, with client verification +// against the CA root pool. Pass key and cert from Issue or +// IssueServer. +func (ca *TestCA) ServerTLSConfig(key crypto.Signer, cert *x509.Certificate) tls.Config { + return tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + Leaf: cert, + }, + }, + ClientCAs: ca.CertPool(), + MinVersion: tls.VersionTLS13, + } +} + +// TLSKeyPair returns a tls.Certificate from the given key and +// certificate, suitable for use in a tls.Config.Certificates slice. +func TLSKeyPair(key crypto.Signer, cert *x509.Certificate) tls.Certificate { + return tls.Certificate{ + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + Leaf: cert, + } +} + +// MustTestCA calls NewTestCA and panics on error. Intended for use +// in TestMain or test helpers where error handling is impractical. +func MustTestCA() *TestCA { + ca, err := NewTestCA() + if err != nil { + panic("certgen: " + err.Error()) + } + return ca +} + +// CertificatePEM returns a PEM-encoded byte slice for the given +// certificate. +func CertificatePEM(cert *x509.Certificate) []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) +} + +// PrivateKeyPEM returns a PEM-encoded PKCS#8 byte slice for the +// given private key. +func PrivateKeyPEM(key crypto.Signer) ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("certgen: marshaling private key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: der, + }), nil +} + diff --git a/certlib/certgen/testca_test.go b/certlib/certgen/testca_test.go new file mode 100644 index 0000000..2ba23fb --- /dev/null +++ b/certlib/certgen/testca_test.go @@ -0,0 +1,223 @@ +package certgen + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewTestCA(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + cert := ca.Certificate() + if !cert.IsCA { + t.Fatal("expected CA certificate") + } + if cert.Subject.CommonName != "Test CA" { + t.Fatalf("got CN %q, want %q", cert.Subject.CommonName, "Test CA") + } +} + +func TestCertificatePEMRoundtrip(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + pemBytes := ca.CertificatePEM() + block, _ := pem.Decode(pemBytes) + if block == nil { + t.Fatal("failed to decode PEM") + } + if block.Type != "CERTIFICATE" { + t.Fatalf("got PEM type %q, want CERTIFICATE", block.Type) + } + + parsed, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + if !parsed.Equal(ca.Certificate()) { + t.Fatal("parsed certificate does not match original") + } +} + +func TestCertPool(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + pool := ca.CertPool() + + // Verify the CA cert validates against its own pool. + chains, err := ca.Certificate().Verify(x509.VerifyOptions{ + Roots: pool, + }) + if err != nil { + t.Fatalf("verify CA cert against its own pool: %v", err) + } + if len(chains) == 0 { + t.Fatal("expected at least one chain") + } +} + +func TestIssue(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + dnsNames := []string{"example.test", "www.example.test"} + ips := []net.IP{net.IPv4(10, 0, 0, 1)} + + key, cert, err := ca.Issue(dnsNames, ips) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if key == nil { + t.Fatal("expected non-nil key") + } + if cert.IsCA { + t.Fatal("leaf cert should not be CA") + } + if cert.Subject.CommonName != "example.test" { + t.Fatalf("got CN %q, want %q", cert.Subject.CommonName, "example.test") + } + + // Verify the leaf cert chains to the CA. + _, err = cert.Verify(x509.VerifyOptions{ + Roots: ca.CertPool(), + DNSName: "example.test", + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + if err != nil { + t.Fatalf("verify leaf cert: %v", err) + } +} + +func TestIssueServerTLS(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + key, cert, err := ca.IssueServer() + if err != nil { + t.Fatalf("IssueServer: %v", err) + } + + // Start a TLS server with the issued cert. + serverCfg := ca.ServerTLSConfig(key, cert) + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.TLS = &serverCfg + srv.StartTLS() + defer srv.Close() + + // Create a client that verifies the server cert against the CA. + clientCfg := ca.TLSConfig() + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &clientCfg, + }, + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("GET: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("got status %d, want 200", resp.StatusCode) + } +} + +func TestTLSConfigReturnsByValue(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + cfg1 := ca.TLSConfig() + cfg2 := ca.TLSConfig() + + // Modifying one should not affect the other. + cfg1.ServerName = "modified" + if cfg2.ServerName == "modified" { + t.Fatal("TLSConfig should return independent values") + } +} + +func TestTLSConfigEnforcesTLS13(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + cfg := ca.TLSConfig() + if cfg.MinVersion != tls.VersionTLS13 { + t.Fatalf("got MinVersion %d, want TLS 1.3 (%d)", cfg.MinVersion, tls.VersionTLS13) + } +} + +func TestMustTestCA(t *testing.T) { + // Should not panic. + ca := MustTestCA() + if ca.Certificate() == nil { + t.Fatal("expected non-nil certificate") + } +} + +func TestPrivateKeyPEM(t *testing.T) { + ca, err := NewTestCA() + if err != nil { + t.Fatalf("NewTestCA: %v", err) + } + + key, _, err := ca.IssueServer() + if err != nil { + t.Fatalf("IssueServer: %v", err) + } + + pemBytes, err := PrivateKeyPEM(key) + if err != nil { + t.Fatalf("PrivateKeyPEM: %v", err) + } + + block, _ := pem.Decode(pemBytes) + if block == nil { + t.Fatal("failed to decode PEM") + } + if block.Type != "PRIVATE KEY" { + t.Fatalf("got PEM type %q, want PRIVATE KEY", block.Type) + } +} + +func TestUntrustedCAFails(t *testing.T) { + ca1 := MustTestCA() + ca2 := MustTestCA() + + // Issue a cert from ca1, try to verify against ca2's pool. + _, cert, err := ca1.IssueServer() + if err != nil { + t.Fatalf("IssueServer: %v", err) + } + + _, err = cert.Verify(x509.VerifyOptions{ + Roots: ca2.CertPool(), + DNSName: "localhost", + }) + if err == nil { + t.Fatal("expected verification to fail with wrong CA") + } +}