Repository: letsencrypt/boulder
Pull request #8065: identifier: Add FromCert & FromCSR; move Normalize from core
Author: login: jprenken
Created: 2025-03-13T08:30:07Z | Merged: 2025-03-19T21:04:00Z

Part of #7311


Diff:
diff --git a/core/util.go b/core/util.go
index fb514207b3d..307bc1c4139 100644
--- a/core/util.go
+++ b/core/util.go
@@ -21,7 +21,6 @@ import (
 	"path"
 	"reflect"
 	"regexp"
-	"slices"
 	"sort"
 	"strings"
 	"time"
@@ -32,8 +31,6 @@ import (
 	"google.golang.org/grpc/status"
 	"google.golang.org/protobuf/types/known/durationpb"
 	"google.golang.org/protobuf/types/known/timestamppb"
-
-	"github.com/letsencrypt/boulder/identifier"
 )
 
 const Unspecified = "Unspecified"
@@ -322,21 +319,6 @@ func UniqueLowerNames(names []string) (unique []string) {
 	return
 }
 
-// NormalizeIdentifiers returns the set of all unique ACME identifiers in the
-// input after all of them are lowercased. The returned identifier values will
-// be in their lowercased form and sorted alphabetically by value.
-func NormalizeIdentifiers(identifiers []identifier.ACMEIdentifier) []identifier.ACMEIdentifier {
-	for i := range identifiers {
-		identifiers[i].Value = strings.ToLower(identifiers[i].Value)
-	}
-
-	sort.Slice(identifiers, func(i, j int) bool {
-		return fmt.Sprintf("%s:%s", identifiers[i].Type, identifiers[i].Value) < fmt.Sprintf("%s:%s", identifiers[j].Type, identifiers[j].Value)
-	})
-
-	return slices.Compact(identifiers)
-}
-
 // HashNames returns a hash of the names requested. This is intended for use
 // when interacting with the orderFqdnSets table and rate limiting.
 func HashNames(names []string) []byte {
diff --git a/core/util_test.go b/core/util_test.go
index 7ba423c471e..6a123c6e679 100644
--- a/core/util_test.go
+++ b/core/util_test.go
@@ -20,7 +20,6 @@ import (
 	"google.golang.org/protobuf/types/known/durationpb"
 	"google.golang.org/protobuf/types/known/timestamppb"
 
-	"github.com/letsencrypt/boulder/identifier"
 	"github.com/letsencrypt/boulder/test"
 )
 
@@ -255,26 +254,6 @@ func TestUniqueLowerNames(t *testing.T) {
 	test.AssertDeepEquals(t, []string{"a.com", "bar.com", "baz.com", "foobar.com"}, u)
 }
 
-func TestNormalizeIdentifiers(t *testing.T) {
-	idents := []identifier.ACMEIdentifier{
-		{Type: "DNS", Value: "foobar.com"},
-		{Type: "DNS", Value: "fooBAR.com"},
-		{Type: "DNS", Value: "baz.com"},
-		{Type: "DNS", Value: "foobar.com"},
-		{Type: "DNS", Value: "bar.com"},
-		{Type: "DNS", Value: "bar.com"},
-		{Type: "DNS", Value: "a.com"},
-	}
-	expected := []identifier.ACMEIdentifier{
-		{Type: "DNS", Value: "a.com"},
-		{Type: "DNS", Value: "bar.com"},
-		{Type: "DNS", Value: "baz.com"},
-		{Type: "DNS", Value: "foobar.com"},
-	}
-	u := NormalizeIdentifiers(idents)
-	test.AssertDeepEquals(t, expected, u)
-}
-
 func TestValidSerial(t *testing.T) {
 	notLength32Or36 := "A"
 	length32 := strings.Repeat("A", 32)
diff --git a/csr/csr.go b/csr/csr.go
index 2d2f8d07de0..c924d893721 100644
--- a/csr/csr.go
+++ b/csr/csr.go
@@ -100,6 +100,10 @@ type names struct {
 // will be the first SAN that is short enough, which is done only for backwards
 // compatibility with prior Let's Encrypt behaviour. The resulting SANs will
 // always include the original CN, if any.
+//
+// TODO(#7311): For callers that don't care about CNs, use identifier.FromCSR.
+// For the rest, either revise the names struct to hold identifiers instead of
+// strings, or add an ipSANs field (and rename SANs to dnsSANs).
 func NamesFromCSR(csr *x509.CertificateRequest) names {
 	// Produce a new "sans" slice with the same memory address as csr.DNSNames
 	// but force a new allocation if an append happens so that we don't
diff --git a/identifier/identifier.go b/identifier/identifier.go
index b50e8955f23..9c1dae8f6f9 100644
--- a/identifier/identifier.go
+++ b/identifier/identifier.go
@@ -4,7 +4,11 @@
 package identifier
 
 import (
+	"crypto/x509"
+	"net"
 	"net/netip"
+	"slices"
+	"strings"
 
 	corepb "github.com/letsencrypt/boulder/core/proto"
 )
@@ -85,3 +89,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
 		Value: ip.String(),
 	}
 }
+
+// fromX509 extracts the Subject Alternative Names from a certificate or CSR's fields, and
+// returns a slice of ACMEIdentifiers.
+func fromX509(commonName string, dnsNames []string, ipAddresses []net.IP) []ACMEIdentifier {
+	var sans []ACMEIdentifier
+	for _, name := range dnsNames {
+		sans = append(sans, NewDNS(name))
+	}
+	if commonName != "" {
+		// Boulder won't generate certificates with a CN that's not also present
+		// in the SANs, but such a certificate is possible. If appended, this is
+		// deduplicated later with Normalize(). We assume the CN is a DNSName,
+		// because CNs are untyped strings without metadata, and we will never
+		// configure a Boulder profile to issue a certificate that contains both
+		// an IP address identifier and a CN.
+		sans = append(sans, NewDNS(commonName))
+	}
+
+	for _, ip := range ipAddresses {
+		sans = append(sans, ACMEIdentifier{
+			Type:  TypeIP,
+			Value: ip.String(),
+		})
+	}
+
+	return Normalize(sans)
+}
+
+// FromCert extracts the Subject Common Name and Subject Alternative Names from
+// a certificate, and returns a slice of ACMEIdentifiers.
+func FromCert(cert *x509.Certificate) []ACMEIdentifier {
+	return fromX509(cert.Subject.CommonName, cert.DNSNames, cert.IPAddresses)
+}
+
+// FromCSR extracts the Subject Common Name and Subject Alternative Names from a
+// CSR, and returns a slice of ACMEIdentifiers.
+func FromCSR(csr *x509.CertificateRequest) []ACMEIdentifier {
+	return fromX509(csr.Subject.CommonName, csr.DNSNames, csr.IPAddresses)
+}
+
+// Normalize returns the set of all unique ACME identifiers in the input after
+// all of them are lowercased. The returned identifier values will be in their
+// lowercased form and sorted alphabetically by value. DNS identifiers will
+// precede IP address identifiers.
+func Normalize(idents []ACMEIdentifier) []ACMEIdentifier {
+	for i := range idents {
+		idents[i].Value = strings.ToLower(idents[i].Value)
+	}
+
+	slices.SortFunc(idents, func(a, b ACMEIdentifier) int {
+		if a.Type == b.Type {
+			if a.Value == b.Value {
+				return 0
+			}
+			if a.Value < b.Value {
+				return -1
+			}
+			return 1
+		}
+		if a.Type == "dns" && b.Type == "ip" {
+			return -1
+		}
+		return 1
+	})
+
+	return slices.Compact(idents)
+}
diff --git a/identifier/identifier_test.go b/identifier/identifier_test.go
new file mode 100644
index 00000000000..af717b93f7d
--- /dev/null
+++ b/identifier/identifier_test.go
@@ -0,0 +1,147 @@
+package identifier
+
+import (
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"net"
+	"net/netip"
+	"slices"
+	"testing"
+)
+
+// TestFromX509 tests FromCert and FromCSR, which are fromX509's public
+// wrappers.
+func TestFromX509(t *testing.T) {
+	cases := []struct {
+		name        string
+		subject     pkix.Name
+		dnsNames    []string
+		ipAddresses []net.IP
+		want        []ACMEIdentifier
+	}{
+		{
+			name:     "no explicit CN",
+			dnsNames: []string{"a.com"},
+			want:     []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:     "explicit uppercase CN",
+			subject:  pkix.Name{CommonName: "A.com"},
+			dnsNames: []string{"a.com"},
+			want:     []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:     "no explicit CN, uppercase SAN",
+			dnsNames: []string{"A.com"},
+			want:     []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:     "duplicate SANs",
+			dnsNames: []string{"b.com", "b.com", "a.com", "a.com"},
+			want:     []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:     "explicit CN not found in SANs",
+			subject:  pkix.Name{CommonName: "a.com"},
+			dnsNames: []string{"b.com"},
+			want:     []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:        "mix of DNSNames and IPAddresses",
+			dnsNames:    []string{"a.com"},
+			ipAddresses: []net.IP{{192, 168, 1, 1}},
+			want:        []ACMEIdentifier{NewDNS("a.com"), NewIP(netip.MustParseAddr("192.168.1.1"))},
+		},
+	}
+	for _, tc := range cases {
+		t.Run("cert/"+tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := FromCert(&x509.Certificate{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
+			if !slices.Equal(got, tc.want) {
+				t.Errorf("FromCert() got %#v, but want %#v", got, tc.want)
+			}
+		})
+		t.Run("csr/"+tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := FromCSR(&x509.CertificateRequest{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
+			if !slices.Equal(got, tc.want) {
+				t.Errorf("FromCSR() got %#v, but want %#v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestNormalize(t *testing.T) {
+	cases := []struct {
+		name   string
+		idents []ACMEIdentifier
+		want   []ACMEIdentifier
+	}{
+		{
+			name: "convert to lowercase",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "AlPha.example.coM"},
+				{Type: TypeIP, Value: "fe80::CAFE"},
+			},
+			want: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "sort",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "foobar.com"},
+				{Type: TypeDNS, Value: "bar.com"},
+				{Type: TypeDNS, Value: "baz.com"},
+				{Type: TypeDNS, Value: "a.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+				{Type: TypeIP, Value: "2001:db8::1dea"},
+				{Type: TypeIP, Value: "192.168.1.1"},
+			},
+			want: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "a.com"},
+				{Type: TypeDNS, Value: "bar.com"},
+				{Type: TypeDNS, Value: "baz.com"},
+				{Type: TypeDNS, Value: "foobar.com"},
+				{Type: TypeIP, Value: "192.168.1.1"},
+				{Type: TypeIP, Value: "2001:db8::1dea"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "de-duplicate",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "AlPha.example.coM"},
+				{Type: TypeIP, Value: "fe80::CAFE"},
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+				NewIP(netip.MustParseAddr("fe80:0000:0000:0000:0000:0000:0000:cafe")),
+			},
+			want: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "DNS before IP",
+			idents: []ACMEIdentifier{
+				{Type: TypeIP, Value: "fe80::cafe"},
+				{Type: TypeDNS, Value: "alpha.example.com"},
+			},
+			want: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := Normalize(tc.idents)
+			if !slices.Equal(got, tc.want) {
+				t.Errorf("Got %#v, but want %#v", got, tc.want)
+			}
+		})
+	}
+}
diff --git a/wfe2/wfe.go b/wfe2/wfe.go
index 0e14a778e81..e971739ba24 100644
--- a/wfe2/wfe.go
+++ b/wfe2/wfe.go
@@ -2205,7 +2205,7 @@ func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error
 }
 
 func (wfe *WebFrontEndImpl) checkIdentifiersPaused(ctx context.Context, orderIdentifiers []identifier.ACMEIdentifier, regID int64) ([]string, error) {
-	uniqueOrderIdents := core.NormalizeIdentifiers(orderIdentifiers)
+	uniqueOrderIdents := identifier.Normalize(orderIdentifiers)
 	var idents []*corepb.Identifier
 	for _, ident := range uniqueOrderIdents {
 		idents = append(idents, &corepb.Identifier{


Reviews:
user: login: beautifulentropy
body: It’s unfortunate that this results in functions like `identifier.Normalize` and `identifier.FromCSR`, which handle multiple prospective identifiers and return `[]identifier.ACMEIdentifier`. It would feel more natural to use `identifiers.Normalize` or even `identifiers.ACMEIdentifiers.Normalize()`. While that leans more OOP, it might improve clarity. We should consider renaming this package—not in this PR, but as part of a future refactor for this project.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-14T18:10:48Z
user: login: beautifulentropy
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-14T18:32:05Z
user: login: aarongable
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:24:40Z
user: login: jprenken
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:50:26Z
user: login: jprenken
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:51:41Z
user: login: jprenken
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T01:52:35Z
user: login: aarongable
body: LGTM, with two improvements for the tests
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T00:05:20Z
user: login: aarongable
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:35:10Z
user: login: beautifulentropy
body: This came out great, excellent work.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T21:01:36Z

Review comments:
diff_hunk: @@ -99,6 +99,8 @@ type names struct {
 // will be the first SAN that is short enough, which is done only for backwards
 // compatibility with prior Let's Encrypt behaviour. The resulting SANs will
 // always include the original CN, if any.
+//
+// Deprecated: TODO(#7311): Use identifier.FromCSR instead.
path: csr/csr.go
user: login: aarongable
body: I don't think TODO is quite what we want to do -- this returns a `names` struct which differentiates between the CN and SANs, and puts the SANs in a particular order, so it can't be replaced by a function which returns a simple slice. I think the real TODO is for the `names` struct to either hold identifiers instead of strings, or for it to to have `CN, dnsSANs, ipSANs` as fields.
created_at: 2025-03-15T00:07:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +80,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
 		Value: ip.String(),
 	}
 }
+
+// FromCert extracts the Subject Alternative Names from a certificate, and
+// returns a slice of ACMEIdentifiers and an error. It does not extract the
+// Subject Common Name.
path: identifier/identifier.go
user: login: aarongable
body: It seems like it _could_ inspect the CN -- passing the result through Normalize will remove any duplicate introduced by doing so. What's the reasoning for not doing that here?
created_at: 2025-03-15T00:11:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +80,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
 		Value: ip.String(),
 	}
 }
+
+// FromCert extracts the Subject Alternative Names from a certificate, and
+// returns a slice of ACMEIdentifiers and an error. It does not extract the
+// Subject Common Name.
+//
+// FromCSR is similar but handles CSRs, and is kept separate so that it's always
+// clear we are handling an untrusted CSR.
+func FromCert(cert *x509.Certificate) []ACMEIdentifier {
+	var sans []ACMEIdentifier
+	for _, name := range cert.DNSNames {
+		sans = append(sans, NewDNS(name))
+	}
+
+	for _, ip := range cert.IPAddresses {
+		sans = append(sans, ACMEIdentifier{
+			Type:  TypeIP,
+			Value: ip.String(),
+		})
+	}
+
+	return Normalize(sans)
+}
+
+// FromCSR extracts the Subject Common Name and Subject Alternative Names from a
+// CSR, and returns a slice of ACMEIdentifiers and an error.
+//
+// FromCert is similar but handles certs, and is kept separate so that it's
+// always clear we are handling an untrusted CSR.
path: identifier/identifier.go
user: login: aarongable
body: nit: The first half of this sentence is appropriately flip-flopped from the corresponding sentence in FromCert's comment. But then the rest of it is left the same, which feels weird. "FromCert is kept separate so that it's always clear we are handling an untrusted CSR" -- but FromCert isn't handling an untrusted CSR! Maybe this is nothing but it threw my brain for a loop.
created_at: 2025-03-15T00:13:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +80,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
 		Value: ip.String(),
 	}
 }
+
+// FromCert extracts the Subject Alternative Names from a certificate, and
+// returns a slice of ACMEIdentifiers and an error. It does not extract the
+// Subject Common Name.
+//
+// FromCSR is similar but handles CSRs, and is kept separate so that it's always
+// clear we are handling an untrusted CSR.
+func FromCert(cert *x509.Certificate) []ACMEIdentifier {
+	var sans []ACMEIdentifier
+	for _, name := range cert.DNSNames {
+		sans = append(sans, NewDNS(name))
+	}
+
+	for _, ip := range cert.IPAddresses {
+		sans = append(sans, ACMEIdentifier{
+			Type:  TypeIP,
+			Value: ip.String(),
+		})
+	}
+
+	return Normalize(sans)
+}
+
+// FromCSR extracts the Subject Common Name and Subject Alternative Names from a
+// CSR, and returns a slice of ACMEIdentifiers and an error.
+//
+// FromCert is similar but handles certs, and is kept separate so that it's
+// always clear we are handling an untrusted CSR.
+func FromCSR(csr *x509.CertificateRequest) []ACMEIdentifier {
+	var sans []ACMEIdentifier
+	for _, name := range csr.DNSNames {
+		sans = append(sans, NewDNS(name))
+	}
+	if csr.Subject.CommonName != "" {
+		// Boulder won't generate certificates with a CN that's not also present
+		// in the SANs, but such a certificate is possible. If appended, this is
+		// deduplicated later with Normalize(). We assume the CN is a DNSName,
+		// because CNs are untyped strings without metadata, and we will never
+		// configure a Boulder profile to issue a certificate that contains both
+		// an IP address identifier and a CN.
+		sans = append(sans, NewDNS(csr.Subject.CommonName))
+	}
+
+	for _, ip := range csr.IPAddresses {
+		sans = append(sans, ACMEIdentifier{
+			Type:  TypeIP,
+			Value: ip.String(),
+		})
+	}
+
+	return Normalize(sans)
+}
+
+// Normalize returns the set of all unique ACME identifiers in the
+// input after all of them are lowercased. The returned identifier values will
+// be in their lowercased form and sorted alphabetically by value.
+func Normalize(idents []ACMEIdentifier) []ACMEIdentifier {
+	for i := range idents {
+		idents[i].Value = strings.ToLower(idents[i].Value)
+	}
+
+	sort.Slice(idents, func(i, j int) bool {
path: identifier/identifier.go
user: login: aarongable
body: The newer `slices.SortFunc` is [preferred over](https://pkg.go.dev/sort#Slice) `sort.Slice`, and in fact most of the functions in the old `sort` package have been reimplemented in terms of the more efficient functions from the `slices` package.

Also, using string formatting to perform the comparison is probably significantly less efficient than using multiple conditionals (i.e. if their types are the same just compare their values, otherwise return the dns first).
created_at: 2025-03-15T00:21:40Z
author_association: CONTRIBUTOR
path: identifier/identifier_test.go
user: login: aarongable
body: Looks like we're missing a test for FromCert
created_at: 2025-03-15T00:23:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package identifier
+
+import (
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/letsencrypt/boulder/test"
+)
+
+func TestNormalize(t *testing.T) {
path: identifier/identifier_test.go
user: login: aarongable
body: This test should include IP-type identifiers, to show that your sorting logic is always putting them after DNS identifiers.

Also, I'd personally say that this would be better as a table-driven test, testing each aspect of Normalize individually:
- does it lowercase?
- does it sort?
- does it deduplicate?
created_at: 2025-03-15T00:24:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +80,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
 		Value: ip.String(),
 	}
 }
+
+// FromCert extracts the Subject Alternative Names from a certificate, and
+// returns a slice of ACMEIdentifiers and an error. It does not extract the
+// Subject Common Name.
path: identifier/identifier.go
user: login: jprenken
body: That arose from [other review feedback](https://github.com/letsencrypt/boulder/pull/7961#discussion_r1955179676) but the robustness doesn't cost much, and adding it back in will simplify the tests' logic if we test `FromCert` too.
created_at: 2025-03-15T00:50:26Z
author_association: CONTRIBUTOR
path: identifier/identifier_test.go
user: login: jprenken
body: Instead of duplicating `FromCSR`'s test case, I'll change `FromCert` back to parsing the CN, and then extend the same test case to handle both functions.
created_at: 2025-03-15T00:51:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,8 @@ type names struct {
 // will be the first SAN that is short enough, which is done only for backwards
 // compatibility with prior Let's Encrypt behaviour. The resulting SANs will
 // always include the original CN, if any.
+//
+// Deprecated: TODO(#7311): Use identifier.FromCSR instead.
path: csr/csr.go
user: login: jprenken
body: That makes sense; I've modified the TODO accordingly.

I think clarity around handling trusted certs vs. untrusted CSRs is a fairly compelling argument to keep `identifier.FromCert` and `FromCSR` separate. But at the same time, I'm uneasy about those plus `csr.NamesFromCSR` all having very similar logic in three independent implementations. It seems like a tough clarity vs. maintainability tradeoff.

So, to take the opposite approach, we could merge these three into `identifier.FromX509(certOrCsr AnInterface) []ACMEIdentifier, ACMEIdentifier` and put the CN in that second return value, which callers could choose to ignore.
created_at: 2025-03-15T01:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+package identifier
+
+import (
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/letsencrypt/boulder/test"
+)
+
+// TestFromX509 tests FromCert and FromCSR, which are fromX509's public
+// wrappers.
+func TestFromX509(t *testing.T) {
+	cases := []struct {
+		name           string
+		subject        pkix.Name
+		dnsNames       []string
+		ipAddresses    []net.IP
+		expectedIdents []ACMEIdentifier
+	}{
+		{
+			name:           "no explicit CN",
+			dnsNames:       []string{"a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "explicit uppercase CN",
+			subject:        pkix.Name{CommonName: "A.com"},
+			dnsNames:       []string{"a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "no explicit CN, uppercase SAN",
+			dnsNames:       []string{"A.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "duplicate SANs",
+			dnsNames:       []string{"b.com", "b.com", "a.com", "a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:           "explicit CN not found in SANs",
+			subject:        pkix.Name{CommonName: "a.com"},
+			dnsNames:       []string{"b.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:           "mix of DNSNames and IPAddresses",
+			dnsNames:       []string{"a.com"},
+			ipAddresses:    []net.IP{{192, 168, 1, 1}},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewIP(netip.MustParseAddr("192.168.1.1"))},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			cert := &x509.Certificate{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses}
+			csr := &x509.CertificateRequest{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses}
+			test.AssertDeepEquals(t, FromCert(cert), tc.expectedIdents)
+			test.AssertDeepEquals(t, FromCSR(csr), tc.expectedIdents)
+		})
+	}
path: identifier/identifier_test.go
user: login: aarongable
body: Here's a suggestion to do this more in line with our recent best practices (avoiding test.Assert, using got/want instead of actual/expect) and with some extra parallelism thrown in
```suggestion
	for _, tc := range cases {
		t.Run("cert/"+tc.name, func(t *testing.T) {
			t.Parallel()
			got := FromCert(&x509.Certificate{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
			if !slices.Equal(got, want) {
				t.Errorf("FromCert() got %#v, but want %#v", got, tc.want)
			}
		})
		t.Run("csr/"+tc.name, func(t *testing.T) {
			t.Parallel()
			got := FromCSR(&x509.CertificateRequest{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
			if !slices.Equal(got, want) {
				t.Errorf("FromCSR() got %#v, but want %#v", got, tc.want)
			}	
		})
	}
```
created_at: 2025-03-18T00:02:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+package identifier
+
+import (
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/letsencrypt/boulder/test"
+)
+
+// TestFromX509 tests FromCert and FromCSR, which are fromX509's public
+// wrappers.
+func TestFromX509(t *testing.T) {
+	cases := []struct {
+		name           string
+		subject        pkix.Name
+		dnsNames       []string
+		ipAddresses    []net.IP
+		expectedIdents []ACMEIdentifier
+	}{
+		{
+			name:           "no explicit CN",
+			dnsNames:       []string{"a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "explicit uppercase CN",
+			subject:        pkix.Name{CommonName: "A.com"},
+			dnsNames:       []string{"a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "no explicit CN, uppercase SAN",
+			dnsNames:       []string{"A.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com")},
+		},
+		{
+			name:           "duplicate SANs",
+			dnsNames:       []string{"b.com", "b.com", "a.com", "a.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:           "explicit CN not found in SANs",
+			subject:        pkix.Name{CommonName: "a.com"},
+			dnsNames:       []string{"b.com"},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
+		},
+		{
+			name:           "mix of DNSNames and IPAddresses",
+			dnsNames:       []string{"a.com"},
+			ipAddresses:    []net.IP{{192, 168, 1, 1}},
+			expectedIdents: []ACMEIdentifier{NewDNS("a.com"), NewIP(netip.MustParseAddr("192.168.1.1"))},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			cert := &x509.Certificate{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses}
+			csr := &x509.CertificateRequest{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses}
+			test.AssertDeepEquals(t, FromCert(cert), tc.expectedIdents)
+			test.AssertDeepEquals(t, FromCSR(csr), tc.expectedIdents)
+		})
+	}
+}
+
+func TestNormalize(t *testing.T) {
+	cases := []struct {
+		name     string
+		idents   []ACMEIdentifier
+		expected []ACMEIdentifier
+	}{
+		{
+			name: "convert to lowercase",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "AlPha.example.coM"},
+				{Type: TypeIP, Value: "fe80::CAFE"},
+			},
+			expected: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "sort",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "foobar.com"},
+				{Type: TypeDNS, Value: "bar.com"},
+				{Type: TypeDNS, Value: "baz.com"},
+				{Type: TypeDNS, Value: "a.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+				{Type: TypeIP, Value: "2001:db8::1dea"},
+				{Type: TypeIP, Value: "192.168.1.1"},
+			},
+			expected: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "a.com"},
+				{Type: TypeDNS, Value: "bar.com"},
+				{Type: TypeDNS, Value: "baz.com"},
+				{Type: TypeDNS, Value: "foobar.com"},
+				{Type: TypeIP, Value: "192.168.1.1"},
+				{Type: TypeIP, Value: "2001:db8::1dea"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "de-duplicate",
+			idents: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "AlPha.example.coM"},
+				{Type: TypeIP, Value: "fe80::CAFE"},
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+				NewIP(netip.MustParseAddr("fe80:0000:0000:0000:0000:0000:0000:cafe")),
+			},
+			expected: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+		{
+			name: "DNS before IP",
+			idents: []ACMEIdentifier{
+				{Type: TypeIP, Value: "fe80::cafe"},
+				{Type: TypeDNS, Value: "alpha.example.com"},
+			},
+			expected: []ACMEIdentifier{
+				{Type: TypeDNS, Value: "alpha.example.com"},
+				{Type: TypeIP, Value: "fe80::cafe"},
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			test.AssertDeepEquals(t, tc.expected, Normalize(tc.idents))
path: identifier/identifier_test.go
user: login: aarongable
body: Similar suggestion to above. Also, when doing a comparison like this, _always_ put the got/actual value first, and the want/expect value second.
```suggestion
			t.Parallel
			got := Normalize(tc.idents)
			if !slices.Equal(got, tc.want) {
				t.Errorf("Normalize(%#v) got %#v, but want %#v", tc.idents, got, tc.want)
			}
```
created_at: 2025-03-18T00:05:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: jprenken
created_at: 2025-03-14T22:36:22Z
body: > It would feel more natural to use `identifiers.Normalize` or even `identifiers.ACMEIdentifiers.Normalize()`. While that leans more OOP, it might improve clarity. We should consider renaming this package—not in this PR, but as part of a future refactor for this project.

I like this! Made #8068 and I'll make either a followup commit or followup PR, depending on which merges first.
author_association: CONTRIBUTOR
user: login: aarongable
created_at: 2025-03-18T20:45:55Z
body: > It would feel more natural to use `identifiers.Normalize` or even `identifiers.ACMEIdentifiers.Normalize()`.

While I agree that it would feel more natural, having `.Normalize()` be a method also (to me) implies that it will _modify its receiver in place_. I think that's a bad thing -- the call to Normalize could be happening many layers deep in the call stack, and some higher-up function (e.g. a top-level gRPC request handler) might not expect its input to be modified in-place. I think it is clearer and safer for Normalize to be a function that returns a brand new slice, no in-place modification at all.
author_association: CONTRIBUTOR
user: login: beautifulentropy
created_at: 2025-03-19T21:02:19Z
body: > > It would feel more natural to use `identifiers.Normalize` or even `identifiers.ACMEIdentifiers.Normalize()`.
> 
> While I agree that it would feel more natural, having `.Normalize()` be a method also (to me) implies that it will _modify its receiver in place_. I think that's a bad thing -- the call to Normalize could be happening many layers deep in the call stack, and some higher-up function (e.g. a top-level gRPC request handler) might not expect its input to be modified in-place. I think it is clearer and safer for Normalize to be a function that returns a brand new slice, no in-place modification at all.

I agree, perhaps `.Normalize()` was a bad example here. I'm very happy with the outcome.
author_association: MEMBER