Ensure CN is included as a DNS SAN when FQDN.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
104
certlib/certgen/config_test.go
Normal file
104
certlib/certgen/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user