diff --git a/certlib/certgen/config.go b/certlib/certgen/config.go index 1825cf0..2ba6552 100644 --- a/certlib/certgen/config.go +++ b/certlib/certgen/config.go @@ -8,6 +8,7 @@ import ( "fmt" "math/big" "net" + "slices" "strings" "time" @@ -88,12 +89,17 @@ func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateR } } + dnsNames := cs.Subject.DNSNames + if isFQDN(cs.Subject.CommonName) && !slices.Contains(dnsNames, cs.Subject.CommonName) { + dnsNames = append(dnsNames, cs.Subject.CommonName) + } + req := &x509.CertificateRequest{ PublicKeyAlgorithm: 0, PublicKey: getPublic(priv), Subject: subject, EmailAddresses: cs.Subject.Email, - DNSNames: cs.Subject.DNSNames, + DNSNames: dnsNames, IPAddresses: ipAddresses, } @@ -210,6 +216,32 @@ func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey) return p.SignRequest(certTemplate, req, priv) } +// isFQDN returns true if s looks like a fully-qualified domain name. +func isFQDN(s string) bool { + if s == "" { + return false + } + // Must contain at least one dot and no spaces. + if !strings.Contains(s, ".") || strings.ContainsAny(s, " \t") { + return false + } + // Each label must be non-empty and consist of letters, digits, or hyphens. + for label := range strings.SplitSeq(strings.TrimSuffix(s, "."), ".") { + if label == "" { + return false + } + for _, c := range label { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + return false + } + } + if label[0] == '-' || label[len(label)-1] == '-' { + return false + } + } + return true +} + func SerialNumber() (*big.Int, error) { serialNumberBytes := make([]byte, 20) _, err := rand.Read(serialNumberBytes) diff --git a/certlib/certgen/config_test.go b/certlib/certgen/config_test.go new file mode 100644 index 0000000..745b5eb --- /dev/null +++ b/certlib/certgen/config_test.go @@ -0,0 +1,104 @@ +package certgen + +import ( + "slices" + "testing" +) + +func TestIsFQDN(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"example.com", true}, + {"sub.example.com", true}, + {"example.com.", true}, // trailing dot + {"localhost", false}, // no dot + {"", false}, + {"foo bar.com", false}, // space + {"-bad.com", false}, // leading hyphen + {"bad-.com", false}, // trailing hyphen + {"a..b.com", false}, // empty label + } + + for _, tt := range tests { + got := isFQDN(tt.input) + if got != tt.want { + t.Errorf("isFQDN(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestRequestAddsFQDNAsDNSSAN(t *testing.T) { + creq := &CertificateRequest{ + KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256}, + Subject: Subject{ + CommonName: "example.com", + Organization: "Test Org", + }, + Profile: Profile{ + Expiry: "1h", + }, + } + + _, req, err := creq.Generate() + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + if !slices.Contains(req.DNSNames, "example.com") { + t.Errorf("expected DNS SAN to contain %q, got %v", "example.com", req.DNSNames) + } +} + +func TestRequestFQDNNotDuplicated(t *testing.T) { + creq := &CertificateRequest{ + KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256}, + Subject: Subject{ + CommonName: "example.com", + Organization: "Test Org", + DNSNames: []string{"example.com", "www.example.com"}, + }, + Profile: Profile{ + Expiry: "1h", + }, + } + + _, req, err := creq.Generate() + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + count := 0 + for _, name := range req.DNSNames { + if name == "example.com" { + count++ + } + } + + if count != 1 { + t.Errorf("expected exactly 1 occurrence of %q in DNS SANs, got %d: %v", "example.com", count, req.DNSNames) + } +} + +func TestRequestNonFQDNCommonNameNotAdded(t *testing.T) { + creq := &CertificateRequest{ + KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256}, + Subject: Subject{ + CommonName: "localhost", + Organization: "Test Org", + }, + Profile: Profile{ + Expiry: "1h", + }, + } + + _, req, err := creq.Generate() + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + + if slices.Contains(req.DNSNames, "localhost") { + t.Errorf("expected DNS SANs to not contain %q, got %v", "localhost", req.DNSNames) + } +}