Ensure CN is included as a DNS SAN when FQDN.

This commit is contained in:
2026-03-15 14:06:36 -07:00
parent d5cee37433
commit 30b5a6699a
2 changed files with 137 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
"slices"
"strings" "strings"
"time" "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{ req := &x509.CertificateRequest{
PublicKeyAlgorithm: 0, PublicKeyAlgorithm: 0,
PublicKey: getPublic(priv), PublicKey: getPublic(priv),
Subject: subject, Subject: subject,
EmailAddresses: cs.Subject.Email, EmailAddresses: cs.Subject.Email,
DNSNames: cs.Subject.DNSNames, DNSNames: dnsNames,
IPAddresses: ipAddresses, IPAddresses: ipAddresses,
} }
@@ -210,6 +216,32 @@ func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey)
return p.SignRequest(certTemplate, req, priv) 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) { func SerialNumber() (*big.Int, error) {
serialNumberBytes := make([]byte, 20) serialNumberBytes := make([]byte, 20)
_, err := rand.Read(serialNumberBytes) _, err := rand.Read(serialNumberBytes)

View File

@@ -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)
}
}