Repository: go-acme/lego (language: go)

Issue:
Add check for changed order identifier by ACME Server (in case it is misbehaving)
### Welcome

- [x] Yes, I've searched similar issues on GitHub and didn't find any.

### How do you use lego?

Through Traefik

### Effective version of lego

v4.23.1

### Detailed Description

Hello,

first of all, thank you very much for your effort and project. Lego in combination with Traefik is really great!

We recently faced a problem where we wanted to issue a wildcard certificate as part of SANs using a private ACME offering of a CAB member. Our Traefik config was like this:
```yaml
entryPoints:
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: acmeResolver
          domains:
          - main: "test.example.com"
            sans:
              - "*.test.example.com"
```

We were wondering, why we would not get a wildcard certificate, even though Traefik would save the certificate with associated wildcard in _acme.json_. Also, no errors by Lego or Traefik were logged at all.

I then tried with certbot which worked as expected and directly with Lego, which showed the same behaviour as Traefik. To debug this, I added dumps in the do() function is sender.go (https://github.com/kiliant/lego/blob/debug-wildcard/acme/api/internal/sender/sender.go#L88).
It became evident, that the ACME server truncated the wildcard from the respective (actually immutable) identifier (which they must not do, according to [RFC 8555 Section 7.1.3](https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3)). As Lego unmarshals the response from the ACME server into its acme.Order struct, the Identifiers given by the ACME server are used for CSR creation.

Therefore, identifiers `[{Type:dns Value:test.example.com} {Type:dns Value:*.test.example.com}]` became `[{Type:dns Value:test.example.com} {Type:dns Value:test.example.com}]` and the certificate was issued without wildcard.

[RFC 8555 Section 7.1.3](https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3) states:
> The elements of the "authorizations" and "identifiers" arrays are
   immutable once set.  The server MUST NOT change the contents of
   either array after they are created.  **If a client observes a change
   in the contents of either array, then it SHOULD consider the order
   invalid.**

Maybe it would be appropriate to add a check in acme/api/order.go for this in order to detect a misbehaving ACME Server and propagate an error to the (potentially) calling application? E. g. like this:
```go
// Check if the Identifiers changed, which they must not.
// The elements of the "authorizations" and "identifiers" arrays are immutable once set. The server MUST NOT
// change the contents of either array after they are created. If a client observes a change in the contents of
// either array, then it SHOULD consider the order invalid.
// https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3
// Identifier order might be different, thus we need a helper function.
if !IdentifiersMatch(order.Identifiers, orderReq.Identifiers) {
	return acme.ExtendedOrder{},
		fmt.Errorf("order: Order Identifiers do not match: %+v != %+v (ACME Server != ACME Client). "+
		"The order is considered invalid as per RFC 8555 section 7.1.3",
			order.Identifiers, identifiers)
}
```
https://github.com/kiliant/lego/blob/7fdc90b92b2f674503e4ebfbb559d985351b0fa4/acme/api/order.go#L88-L99

Example Lego output with identifier validation:
```bash
./lego.sh
2025/04/28 10:44:44 [INFO] [test.example.com, *.test.example.com] acme: Obtaining bundled SAN certificate
2025/04/28 10:44:44 Could not obtain certificates: order: Order Identifiers do not match: [{Type:dns Value:test.example.com} {Type:dns Value:test.example.com}] != [{Type:dns Value:test.example.com} {Type:dns Value:*.test.example.com}] (ACME Server != ACME Client). The order is considered invalid as per RFC 8555 section 7.1.3
```

Best regards and thanks again
Thomas


Discussion / hints:

Fix patch:
diff --git a/acme/api/identifier.go b/acme/api/identifier.go
new file mode 100644
index 0000000000..27337ccba8
--- /dev/null
+++ b/acme/api/identifier.go
@@ -0,0 +1,27 @@
+package api
+
+import (
+	"cmp"
+	"slices"
+
+	"github.com/go-acme/lego/v4/acme"
+)
+
+// compareIdentifiers compares 2 slices of [acme.Identifier].
+func compareIdentifiers(a, b []acme.Identifier) int {
+	// Clones slices to avoid modifying original slices.
+	right := slices.Clone(a)
+	left := slices.Clone(b)
+
+	slices.SortStableFunc(right, compareIdentifier)
+	slices.SortStableFunc(left, compareIdentifier)
+
+	return slices.CompareFunc(right, left, compareIdentifier)
+}
+
+func compareIdentifier(right, left acme.Identifier) int {
+	return cmp.Or(
+		cmp.Compare(right.Type, left.Type),
+		cmp.Compare(right.Value, left.Value),
+	)
+}
diff --git a/acme/api/order.go b/acme/api/order.go
index 4d310e0409..dd42fb4451 100644
--- a/acme/api/order.go
+++ b/acme/api/order.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"slices"
 	"time"
 
 	"github.com/go-acme/lego/v4/acme"
@@ -85,6 +86,21 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm
 		}
 	}
 
+	// The elements of the "authorizations" and "identifiers" arrays are immutable once set.
+	// The server MUST NOT change the contents of either array after they are created.
+	// If a client observes a change in the contents of either array,
+	// then it SHOULD consider the order invalid.
+	// https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3
+	if compareIdentifiers(orderReq.Identifiers, order.Identifiers) != 0 {
+		// Sorts identifiers to avoid error message ambiguities about the order of the identifiers.
+		slices.SortStableFunc(orderReq.Identifiers, compareIdentifier)
+		slices.SortStableFunc(order.Identifiers, compareIdentifier)
+
+		return acme.ExtendedOrder{},
+			fmt.Errorf("order identifiers have been by the ACME server (RFC8555 §7.1.3): %+v != %+v",
+				orderReq.Identifiers, order.Identifiers)
+	}
+
 	return acme.ExtendedOrder{
 		Order:    order,
 		Location: resp.Header.Get("Location"),


Test patch:
diff --git a/acme/api/identifier_test.go b/acme/api/identifier_test.go
new file mode 100644
index 0000000000..586a879869
--- /dev/null
+++ b/acme/api/identifier_test.go
@@ -0,0 +1,111 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/go-acme/lego/v4/acme"
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_compareIdentifiers(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		a, b     []acme.Identifier
+		expected int
+	}{
+		{
+			desc: "identical identifiers",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			expected: 0,
+		},
+		{
+			desc: "identical identifiers but different order",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "*.example.com"},
+				{Type: "dns", Value: "example.com"},
+			},
+			expected: 0,
+		},
+		{
+			desc: "duplicate identifiers",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "example.com"},
+			},
+			expected: -1,
+		},
+		{
+			desc: "different identifier values",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.org"},
+			},
+			expected: -1,
+		},
+		{
+			desc: "different identifier types",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "ip", Value: "*.example.com"},
+			},
+			expected: -1,
+		},
+		{
+			desc: "different number of identifiers a>b",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+				{Type: "dns", Value: "example.org"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			expected: 1,
+		},
+		{
+			desc: "different number of identifiers b>a",
+			a: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+			},
+			b: []acme.Identifier{
+				{Type: "dns", Value: "example.com"},
+				{Type: "dns", Value: "*.example.com"},
+				{Type: "dns", Value: "example.org"},
+			},
+			expected: -1,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			assert.Equal(t, test.expected, compareIdentifiers(test.a, test.b))
+		})
+	}
+}