Skip to content

Commit b4308df

Browse files
authored
identifier: Add FromCert & FromCSR; move Normalize from core (#8065)
Part of #7311
1 parent 9f4b18c commit b4308df

File tree

6 files changed

+223
-40
lines changed

6 files changed

+223
-40
lines changed

core/util.go

-18
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"path"
2222
"reflect"
2323
"regexp"
24-
"slices"
2524
"sort"
2625
"strings"
2726
"time"
@@ -32,8 +31,6 @@ import (
3231
"google.golang.org/grpc/status"
3332
"google.golang.org/protobuf/types/known/durationpb"
3433
"google.golang.org/protobuf/types/known/timestamppb"
35-
36-
"github.com/letsencrypt/boulder/identifier"
3734
)
3835

3936
const Unspecified = "Unspecified"
@@ -322,21 +319,6 @@ func UniqueLowerNames(names []string) (unique []string) {
322319
return
323320
}
324321

325-
// NormalizeIdentifiers returns the set of all unique ACME identifiers in the
326-
// input after all of them are lowercased. The returned identifier values will
327-
// be in their lowercased form and sorted alphabetically by value.
328-
func NormalizeIdentifiers(identifiers []identifier.ACMEIdentifier) []identifier.ACMEIdentifier {
329-
for i := range identifiers {
330-
identifiers[i].Value = strings.ToLower(identifiers[i].Value)
331-
}
332-
333-
sort.Slice(identifiers, func(i, j int) bool {
334-
return fmt.Sprintf("%s:%s", identifiers[i].Type, identifiers[i].Value) < fmt.Sprintf("%s:%s", identifiers[j].Type, identifiers[j].Value)
335-
})
336-
337-
return slices.Compact(identifiers)
338-
}
339-
340322
// HashNames returns a hash of the names requested. This is intended for use
341323
// when interacting with the orderFqdnSets table and rate limiting.
342324
func HashNames(names []string) []byte {

core/util_test.go

-21
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"google.golang.org/protobuf/types/known/durationpb"
2121
"google.golang.org/protobuf/types/known/timestamppb"
2222

23-
"github.com/letsencrypt/boulder/identifier"
2423
"github.com/letsencrypt/boulder/test"
2524
)
2625

@@ -255,26 +254,6 @@ func TestUniqueLowerNames(t *testing.T) {
255254
test.AssertDeepEquals(t, []string{"a.com", "bar.com", "baz.com", "foobar.com"}, u)
256255
}
257256

258-
func TestNormalizeIdentifiers(t *testing.T) {
259-
idents := []identifier.ACMEIdentifier{
260-
{Type: "DNS", Value: "foobar.com"},
261-
{Type: "DNS", Value: "fooBAR.com"},
262-
{Type: "DNS", Value: "baz.com"},
263-
{Type: "DNS", Value: "foobar.com"},
264-
{Type: "DNS", Value: "bar.com"},
265-
{Type: "DNS", Value: "bar.com"},
266-
{Type: "DNS", Value: "a.com"},
267-
}
268-
expected := []identifier.ACMEIdentifier{
269-
{Type: "DNS", Value: "a.com"},
270-
{Type: "DNS", Value: "bar.com"},
271-
{Type: "DNS", Value: "baz.com"},
272-
{Type: "DNS", Value: "foobar.com"},
273-
}
274-
u := NormalizeIdentifiers(idents)
275-
test.AssertDeepEquals(t, expected, u)
276-
}
277-
278257
func TestValidSerial(t *testing.T) {
279258
notLength32Or36 := "A"
280259
length32 := strings.Repeat("A", 32)

csr/csr.go

+4
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ type names struct {
100100
// will be the first SAN that is short enough, which is done only for backwards
101101
// compatibility with prior Let's Encrypt behaviour. The resulting SANs will
102102
// always include the original CN, if any.
103+
//
104+
// TODO(#7311): For callers that don't care about CNs, use identifier.FromCSR.
105+
// For the rest, either revise the names struct to hold identifiers instead of
106+
// strings, or add an ipSANs field (and rename SANs to dnsSANs).
103107
func NamesFromCSR(csr *x509.CertificateRequest) names {
104108
// Produce a new "sans" slice with the same memory address as csr.DNSNames
105109
// but force a new allocation if an append happens so that we don't

identifier/identifier.go

+71
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
package identifier
1010

1111
import (
12+
"crypto/x509"
13+
"net"
1214
"net/netip"
15+
"slices"
16+
"strings"
1317

1418
corepb "github.com/letsencrypt/boulder/core/proto"
1519
)
@@ -94,3 +98,70 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
9498
Value: ip.String(),
9599
}
96100
}
101+
102+
// fromX509 extracts the Subject Alternative Names from a certificate or CSR's fields, and
103+
// returns a slice of ACMEIdentifiers.
104+
func fromX509(commonName string, dnsNames []string, ipAddresses []net.IP) []ACMEIdentifier {
105+
var sans []ACMEIdentifier
106+
for _, name := range dnsNames {
107+
sans = append(sans, NewDNS(name))
108+
}
109+
if commonName != "" {
110+
// Boulder won't generate certificates with a CN that's not also present
111+
// in the SANs, but such a certificate is possible. If appended, this is
112+
// deduplicated later with Normalize(). We assume the CN is a DNSName,
113+
// because CNs are untyped strings without metadata, and we will never
114+
// configure a Boulder profile to issue a certificate that contains both
115+
// an IP address identifier and a CN.
116+
sans = append(sans, NewDNS(commonName))
117+
}
118+
119+
for _, ip := range ipAddresses {
120+
sans = append(sans, ACMEIdentifier{
121+
Type: TypeIP,
122+
Value: ip.String(),
123+
})
124+
}
125+
126+
return Normalize(sans)
127+
}
128+
129+
// FromCert extracts the Subject Common Name and Subject Alternative Names from
130+
// a certificate, and returns a slice of ACMEIdentifiers.
131+
func FromCert(cert *x509.Certificate) []ACMEIdentifier {
132+
return fromX509(cert.Subject.CommonName, cert.DNSNames, cert.IPAddresses)
133+
}
134+
135+
// FromCSR extracts the Subject Common Name and Subject Alternative Names from a
136+
// CSR, and returns a slice of ACMEIdentifiers.
137+
func FromCSR(csr *x509.CertificateRequest) []ACMEIdentifier {
138+
return fromX509(csr.Subject.CommonName, csr.DNSNames, csr.IPAddresses)
139+
}
140+
141+
// Normalize returns the set of all unique ACME identifiers in the input after
142+
// all of them are lowercased. The returned identifier values will be in their
143+
// lowercased form and sorted alphabetically by value. DNS identifiers will
144+
// precede IP address identifiers.
145+
func Normalize(idents []ACMEIdentifier) []ACMEIdentifier {
146+
for i := range idents {
147+
idents[i].Value = strings.ToLower(idents[i].Value)
148+
}
149+
150+
slices.SortFunc(idents, func(a, b ACMEIdentifier) int {
151+
if a.Type == b.Type {
152+
if a.Value == b.Value {
153+
return 0
154+
}
155+
if a.Value < b.Value {
156+
return -1
157+
}
158+
return 1
159+
}
160+
if a.Type == "dns" && b.Type == "ip" {
161+
return -1
162+
}
163+
return 1
164+
})
165+
166+
return slices.Compact(idents)
167+
}

identifier/identifier_test.go

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package identifier
2+
3+
import (
4+
"crypto/x509"
5+
"crypto/x509/pkix"
6+
"net"
7+
"net/netip"
8+
"slices"
9+
"testing"
10+
)
11+
12+
// TestFromX509 tests FromCert and FromCSR, which are fromX509's public
13+
// wrappers.
14+
func TestFromX509(t *testing.T) {
15+
cases := []struct {
16+
name string
17+
subject pkix.Name
18+
dnsNames []string
19+
ipAddresses []net.IP
20+
want []ACMEIdentifier
21+
}{
22+
{
23+
name: "no explicit CN",
24+
dnsNames: []string{"a.com"},
25+
want: []ACMEIdentifier{NewDNS("a.com")},
26+
},
27+
{
28+
name: "explicit uppercase CN",
29+
subject: pkix.Name{CommonName: "A.com"},
30+
dnsNames: []string{"a.com"},
31+
want: []ACMEIdentifier{NewDNS("a.com")},
32+
},
33+
{
34+
name: "no explicit CN, uppercase SAN",
35+
dnsNames: []string{"A.com"},
36+
want: []ACMEIdentifier{NewDNS("a.com")},
37+
},
38+
{
39+
name: "duplicate SANs",
40+
dnsNames: []string{"b.com", "b.com", "a.com", "a.com"},
41+
want: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
42+
},
43+
{
44+
name: "explicit CN not found in SANs",
45+
subject: pkix.Name{CommonName: "a.com"},
46+
dnsNames: []string{"b.com"},
47+
want: []ACMEIdentifier{NewDNS("a.com"), NewDNS("b.com")},
48+
},
49+
{
50+
name: "mix of DNSNames and IPAddresses",
51+
dnsNames: []string{"a.com"},
52+
ipAddresses: []net.IP{{192, 168, 1, 1}},
53+
want: []ACMEIdentifier{NewDNS("a.com"), NewIP(netip.MustParseAddr("192.168.1.1"))},
54+
},
55+
}
56+
for _, tc := range cases {
57+
t.Run("cert/"+tc.name, func(t *testing.T) {
58+
t.Parallel()
59+
got := FromCert(&x509.Certificate{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
60+
if !slices.Equal(got, tc.want) {
61+
t.Errorf("FromCert() got %#v, but want %#v", got, tc.want)
62+
}
63+
})
64+
t.Run("csr/"+tc.name, func(t *testing.T) {
65+
t.Parallel()
66+
got := FromCSR(&x509.CertificateRequest{Subject: tc.subject, DNSNames: tc.dnsNames, IPAddresses: tc.ipAddresses})
67+
if !slices.Equal(got, tc.want) {
68+
t.Errorf("FromCSR() got %#v, but want %#v", got, tc.want)
69+
}
70+
})
71+
}
72+
}
73+
74+
func TestNormalize(t *testing.T) {
75+
cases := []struct {
76+
name string
77+
idents []ACMEIdentifier
78+
want []ACMEIdentifier
79+
}{
80+
{
81+
name: "convert to lowercase",
82+
idents: []ACMEIdentifier{
83+
{Type: TypeDNS, Value: "AlPha.example.coM"},
84+
{Type: TypeIP, Value: "fe80::CAFE"},
85+
},
86+
want: []ACMEIdentifier{
87+
{Type: TypeDNS, Value: "alpha.example.com"},
88+
{Type: TypeIP, Value: "fe80::cafe"},
89+
},
90+
},
91+
{
92+
name: "sort",
93+
idents: []ACMEIdentifier{
94+
{Type: TypeDNS, Value: "foobar.com"},
95+
{Type: TypeDNS, Value: "bar.com"},
96+
{Type: TypeDNS, Value: "baz.com"},
97+
{Type: TypeDNS, Value: "a.com"},
98+
{Type: TypeIP, Value: "fe80::cafe"},
99+
{Type: TypeIP, Value: "2001:db8::1dea"},
100+
{Type: TypeIP, Value: "192.168.1.1"},
101+
},
102+
want: []ACMEIdentifier{
103+
{Type: TypeDNS, Value: "a.com"},
104+
{Type: TypeDNS, Value: "bar.com"},
105+
{Type: TypeDNS, Value: "baz.com"},
106+
{Type: TypeDNS, Value: "foobar.com"},
107+
{Type: TypeIP, Value: "192.168.1.1"},
108+
{Type: TypeIP, Value: "2001:db8::1dea"},
109+
{Type: TypeIP, Value: "fe80::cafe"},
110+
},
111+
},
112+
{
113+
name: "de-duplicate",
114+
idents: []ACMEIdentifier{
115+
{Type: TypeDNS, Value: "AlPha.example.coM"},
116+
{Type: TypeIP, Value: "fe80::CAFE"},
117+
{Type: TypeDNS, Value: "alpha.example.com"},
118+
{Type: TypeIP, Value: "fe80::cafe"},
119+
NewIP(netip.MustParseAddr("fe80:0000:0000:0000:0000:0000:0000:cafe")),
120+
},
121+
want: []ACMEIdentifier{
122+
{Type: TypeDNS, Value: "alpha.example.com"},
123+
{Type: TypeIP, Value: "fe80::cafe"},
124+
},
125+
},
126+
{
127+
name: "DNS before IP",
128+
idents: []ACMEIdentifier{
129+
{Type: TypeIP, Value: "fe80::cafe"},
130+
{Type: TypeDNS, Value: "alpha.example.com"},
131+
},
132+
want: []ACMEIdentifier{
133+
{Type: TypeDNS, Value: "alpha.example.com"},
134+
{Type: TypeIP, Value: "fe80::cafe"},
135+
},
136+
},
137+
}
138+
for _, tc := range cases {
139+
t.Run(tc.name, func(t *testing.T) {
140+
t.Parallel()
141+
got := Normalize(tc.idents)
142+
if !slices.Equal(got, tc.want) {
143+
t.Errorf("Got %#v, but want %#v", got, tc.want)
144+
}
145+
})
146+
}
147+
}

wfe2/wfe.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -2200,7 +2200,7 @@ func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error
22002200
}
22012201

22022202
func (wfe *WebFrontEndImpl) checkIdentifiersPaused(ctx context.Context, orderIdentifiers []identifier.ACMEIdentifier, regID int64) ([]string, error) {
2203-
uniqueOrderIdents := core.NormalizeIdentifiers(orderIdentifiers)
2203+
uniqueOrderIdents := identifier.Normalize(orderIdentifiers)
22042204
var idents []*corepb.Identifier
22052205
for _, ident := range uniqueOrderIdents {
22062206
idents = append(idents, &corepb.Identifier{

0 commit comments

Comments
 (0)