Repository: smallstep/crypto (go)

Issue:
Update x509util.Certificate to manage policies
### Description

In Go 1.24, the field `PolicyIdentifiers` on `x509.Certificate` has changed, and only `Policies` is marshaled by default. See this in https://pkg.go.dev/crypto/x509#CreateCertificate:

The PolicyIdentifier and Policies fields can both be used to marshal certificate policy OIDs. By default, only the Policies is marshaled, but if the GODEBUG setting "x509usepolicies" has the value "0", the PolicyIdentifiers field will be marshaled instead of the Policies field. This changed in Go 1.24. The Policies field can be used to marshal policy OIDs which have components that are larger than 31 bits.

This will be the default behavior once go.mod is set to 1.24



Pull request:
Fix templates with policies with Go 1.24
### Description

This commit fixes templates with policy identifiers on a program with the go.mod set to 1.24. With Go 1.24, the policies extension is marshaled using the values in the Policies field, but with Go 1.23 or a previous version, the marshaled field is PolicyIdentifiers.

This commit sets the same values on Policies and PolicyIdentifiers, ensuring that the templates with policies work in different versions of Go.

Fixes #738


Patch:
diff --git a/x509util/extensions.go b/x509util/extensions.go
index 5f51343..4c92aa4 100644
--- a/x509util/extensions.go
+++ b/x509util/extensions.go
@@ -890,16 +890,16 @@ func (u CRLDistributionPoints) Set(c *x509.Certificate) {
 
 // PolicyIdentifiers represents the list of OIDs to set in the certificate
 // policies extension.
-type PolicyIdentifiers MultiObjectIdentifier
+type PolicyIdentifiers MultiOID
 
 // MarshalJSON implements the json.Marshaler interface in PolicyIdentifiers.
 func (p PolicyIdentifiers) MarshalJSON() ([]byte, error) {
-	return MultiObjectIdentifier(p).MarshalJSON()
+	return MultiOID(p).MarshalJSON()
 }
 
 // UnmarshalJSON implements the json.Unmarshaler interface in PolicyIdentifiers.
 func (p *PolicyIdentifiers) UnmarshalJSON(data []byte) error {
-	var v MultiObjectIdentifier
+	var v MultiOID
 	if err := json.Unmarshal(data, &v); err != nil {
 		return errors.Wrap(err, "error unmarshaling json")
 	}
@@ -907,9 +907,20 @@ func (p *PolicyIdentifiers) UnmarshalJSON(data []byte) error {
 	return nil
 }
 
-// Set sets the policy identifiers to the given certificate.
+// Set sets the policy identifiers to the given certificate. To ensure
+// compatibility between different versions of Go, set will set
+// PolicyIdentifiers and Policies with the same data.
+//
+// Programs using go.mod 1.24+ will only marshal the Policies field, older
+// versions will only marshal PolicyIdentifiers. This can be changed with the
+// GODEBUG setting "x509usepolicies".
 func (p PolicyIdentifiers) Set(c *x509.Certificate) {
-	c.PolicyIdentifiers = p
+	c.Policies = p
+	for _, pp := range p {
+		if oid, err := parseObjectIdentifier(pp.String()); err == nil {
+			c.PolicyIdentifiers = append(c.PolicyIdentifiers, oid)
+		}
+	}
 }
 
 // BasicConstraints represents the X509 basic constraints extension and defines
diff --git a/x509util/marshal_utils.go b/x509util/marshal_utils.go
index 4408e41..294d3dc 100644
--- a/x509util/marshal_utils.go
+++ b/x509util/marshal_utils.go
@@ -1,6 +1,7 @@
 package x509util
 
 import (
+	"crypto/x509"
 	"encoding/asn1"
 	"encoding/json"
 	"net"
@@ -129,6 +130,44 @@ func (m *MultiURL) UnmarshalJSON(data []byte) error {
 	return nil
 }
 
+// MultiOID is a type used to unmarshal a JSON string or an array
+// of strings into a []x509.OID.
+type MultiOID []x509.OID
+
+// MarshalJSON implements the json.Marshaler interface for MultiOID.
+func (m MultiOID) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return []byte("null"), nil
+	}
+	oids := make([]string, len(m))
+	for i, u := range m {
+		oids[i] = u.String()
+	}
+	return json.Marshal(oids)
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for
+// MultiOID.
+func (m *MultiOID) UnmarshalJSON(data []byte) error {
+	ms, err := unmarshalMultiString(data)
+	if err != nil {
+		return err
+	}
+	if ms != nil {
+		oids := make([]x509.OID, len(ms))
+		for i, s := range ms {
+			oid, err := x509.ParseOID(s)
+			if err != nil {
+				return err
+			}
+			oids[i] = oid
+		}
+
+		*m = MultiOID(oids)
+	}
+	return nil
+}
+
 // MultiObjectIdentifier is a type used to unmarshal a JSON string or an array
 // of strings into a []asn1.ObjectIdentifier.
 type MultiObjectIdentifier []asn1.ObjectIdentifier


Test patch:
diff --git a/x509util/certificate_test.go b/x509util/certificate_test.go
index 2224e87..4649292 100644
--- a/x509util/certificate_test.go
+++ b/x509util/certificate_test.go
@@ -26,6 +26,14 @@ import (
 	"go.step.sm/crypto/pemutil"
 )
 
+func mustOID(t *testing.T, s string) x509.OID {
+	t.Helper()
+
+	oid, err := x509.ParseOID(s)
+	require.NoError(t, err)
+	return oid
+}
+
 func createCertificateRequest(t *testing.T, commonName string, sans []string) (*x509.CertificateRequest, crypto.Signer) {
 	dnsNames, ips, emails, uris := SplitSANs(sans)
 	t.Helper()
@@ -258,7 +266,7 @@ func TestNewCertificate(t *testing.T) {
 			OCSPServer:            []string{"https://ocsp.server"},
 			IssuingCertificateURL: []string{"https://ca.com"},
 			CRLDistributionPoints: []string{"https://ca.com/ca.crl"},
-			PolicyIdentifiers:     PolicyIdentifiers{[]int{1, 2, 3, 4, 5, 6}},
+			PolicyIdentifiers:     PolicyIdentifiers{mustOID(t, "1.2.3.4.5.6")},
 			BasicConstraints: &BasicConstraints{
 				IsCA:       false,
 				MaxPathLen: 0,
@@ -612,7 +620,7 @@ func TestNewCertificateFromX509(t *testing.T) {
 			OCSPServer:            []string{"https://ocsp.server"},
 			IssuingCertificateURL: []string{"https://ca.com"},
 			CRLDistributionPoints: []string{"https://ca.com/ca.crl"},
-			PolicyIdentifiers:     PolicyIdentifiers{[]int{1, 2, 3, 4, 5, 6}},
+			PolicyIdentifiers:     PolicyIdentifiers{mustOID(t, "1.2.3.4.5.6")},
 			BasicConstraints: &BasicConstraints{
 				IsCA:       false,
 				MaxPathLen: 0,
@@ -715,7 +723,7 @@ func TestCertificate_GetCertificate(t *testing.T) {
 			OCSPServer:            []string{"https://oscp.server"},
 			IssuingCertificateURL: []string{"https://ca.com"},
 			CRLDistributionPoints: []string{"https://ca.com/crl"},
-			PolicyIdentifiers:     []asn1.ObjectIdentifier{[]int{1, 2, 3, 4}},
+			PolicyIdentifiers:     PolicyIdentifiers{mustOID(t, "1.2.3.4")},
 			BasicConstraints:      &BasicConstraints{IsCA: true, MaxPathLen: 0},
 			NameConstraints:       &NameConstraints{PermittedDNSDomains: []string{"foo.bar"}},
 			SignatureAlgorithm:    SignatureAlgorithm(x509.PureEd25519),
@@ -744,7 +752,8 @@ func TestCertificate_GetCertificate(t *testing.T) {
 			OCSPServer:            []string{"https://oscp.server"},
 			IssuingCertificateURL: []string{"https://ca.com"},
 			CRLDistributionPoints: []string{"https://ca.com/crl"},
-			PolicyIdentifiers:     []asn1.ObjectIdentifier{[]int{1, 2, 3, 4}},
+			PolicyIdentifiers:     []asn1.ObjectIdentifier{{1, 2, 3, 4}},
+			Policies:              []x509.OID{mustOID(t, "1.2.3.4")},
 			IsCA:                  true,
 			MaxPathLen:            0,
 			MaxPathLenZero:        true,
@@ -785,9 +794,7 @@ func TestCertificate_GetCertificate(t *testing.T) {
 				PublicKeyAlgorithm:    tt.fields.PublicKeyAlgorithm,
 				PublicKey:             tt.fields.PublicKey,
 			}
-			if got := c.GetCertificate(); !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("Certificate.GetCertificate() = %v, want %v", got, tt.want)
-			}
+			assert.Equal(t, tt.want, c.GetCertificate())
 		})
 	}
 }
diff --git a/x509util/extensions_test.go b/x509util/extensions_test.go
index 7c80b44..a28c9c6 100644
--- a/x509util/extensions_test.go
+++ b/x509util/extensions_test.go
@@ -6,6 +6,7 @@ import (
 	"crypto/x509/pkix"
 	"encoding/asn1"
 	"encoding/json"
+	"fmt"
 	"math/big"
 	"net"
 	"net/url"
@@ -967,9 +968,7 @@ func TestCRLDistributionPoints_Set(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			tt.o.Set(tt.args.c)
-			if !reflect.DeepEqual(tt.args.c, tt.want) {
-				t.Errorf("CRLDistributionPoints.Set() = %v, want %v", tt.args.c, tt.want)
-			}
+			assert.Equal(t, tt.want, tt.args.c)
 		})
 	}
 }
@@ -981,8 +980,8 @@ func TestPolicyIdentifiers_MarshalJSON(t *testing.T) {
 		want    []byte
 		wantErr bool
 	}{
-		{"ok", []asn1.ObjectIdentifier{[]int{1, 2, 3, 4}, []int{5, 6, 7, 8, 9, 0}}, []byte(`["1.2.3.4","5.6.7.8.9.0"]`), false},
-		{"empty", []asn1.ObjectIdentifier{}, []byte(`[]`), false},
+		{"ok", []x509.OID{mustOID(t, "1.2.3.4"), mustOID(t, "1.3.5.7")}, []byte(`["1.2.3.4","1.3.5.7"]`), false},
+		{"empty", []x509.OID{}, []byte(`[]`), false},
 		{"nil", nil, []byte(`null`), false},
 	}
 	for _, tt := range tests {
@@ -1009,9 +1008,9 @@ func TestPolicyIdentifiers_UnmarshalJSON(t *testing.T) {
 		want    PolicyIdentifiers
 		wantErr bool
 	}{
-		{"string", args{[]byte(`"1.2.3.4"`)}, []asn1.ObjectIdentifier{[]int{1, 2, 3, 4}}, false},
-		{"array", args{[]byte(`["1.2.3.4", "5.6.7.8.9.0"]`)}, []asn1.ObjectIdentifier{[]int{1, 2, 3, 4}, []int{5, 6, 7, 8, 9, 0}}, false},
-		{"empty", args{[]byte(`[]`)}, []asn1.ObjectIdentifier{}, false},
+		{"string", args{[]byte(`"1.2.3.4"`)}, []x509.OID{mustOID(t, "1.2.3.4")}, false},
+		{"array", args{[]byte(`["1.2.3.4", "1.3.5.7"]`)}, []x509.OID{mustOID(t, "1.2.3.4"), mustOID(t, "1.3.5.7")}, false},
+		{"empty", args{[]byte(`[]`)}, []x509.OID{}, false},
 		{"null", args{[]byte(`null`)}, nil, false},
 		{"fail", args{[]byte(`":foo:bar"`)}, nil, true},
 		{"failJSON", args{[]byte(`["https://iss#sub"`)}, nil, true},
@@ -1029,29 +1028,6 @@ func TestPolicyIdentifiers_UnmarshalJSON(t *testing.T) {
 	}
 }
 
-func TestPolicyIdentifiers_Set(t *testing.T) {
-	type args struct {
-		c *x509.Certificate
-	}
-	tests := []struct {
-		name string
-		o    PolicyIdentifiers
-		args args
-		want *x509.Certificate
-	}{
-		{"ok", []asn1.ObjectIdentifier{{1, 2, 3, 4}}, args{&x509.Certificate{}}, &x509.Certificate{PolicyIdentifiers: []asn1.ObjectIdentifier{{1, 2, 3, 4}}}},
-		{"overwrite", []asn1.ObjectIdentifier{{1, 2, 3, 4}, {4, 3, 2, 1}}, args{&x509.Certificate{PolicyIdentifiers: []asn1.ObjectIdentifier{{1, 2, 3, 4}}}}, &x509.Certificate{PolicyIdentifiers: []asn1.ObjectIdentifier{{1, 2, 3, 4}, {4, 3, 2, 1}}}},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			tt.o.Set(tt.args.c)
-			if !reflect.DeepEqual(tt.args.c, tt.want) {
-				t.Errorf("PolicyIdentifiers.Set() = %v, want %v", tt.args.c, tt.want)
-			}
-		})
-	}
-}
-
 func TestBasicConstraints_Set(t *testing.T) {
 	type fields struct {
 		IsCA       bool
@@ -1490,3 +1466,202 @@ func TestParseSubjectAlternativeNames(t *testing.T) {
 		})
 	}
 }
+
+func TestPolicyIdentifiers(t *testing.T) {
+	iss, issPriv := createIssuerCertificate(t, "issuer")
+	csr, _ := createCertificateRequest(t, "", []string{})
+
+	buildWithExtension := func(s string) string {
+		return fmt.Sprintf(`{
+			"subject": {{ toJson .Subject }},
+			"sans": {{ toJson .SANs }},
+		{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
+			"keyUsage": ["keyEncipherment", "digitalSignature"],
+		{{- else }}
+			"keyUsage": ["digitalSignature"],
+		{{- end }}
+			"extKeyUsage": ["serverAuth", "clientAuth"],
+			"extensions": [%s]
+		}`, s)
+	}
+
+	assertValue := func(t *testing.T, want []byte, crt *x509.Certificate) {
+		t.Helper()
+		for _, ext := range crt.Extensions {
+			if ext.Id.Equal(asn1.ObjectIdentifier{2, 5, 29, 32}) {
+				assert.Equal(t, want, ext.Value)
+				return
+			}
+		}
+		t.Error("extension not found")
+	}
+
+	var (
+		policyIdentifiersTemplate = `{
+			"subject": {{ toJson .Subject }},
+			"sans": {{ toJson .SANs }},
+			{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
+			"keyUsage": ["keyEncipherment", "digitalSignature"],
+			{{- else }}
+			"keyUsage": ["digitalSignature"],
+			{{- end }}
+			"extKeyUsage": ["serverAuth", "clientAuth"],
+			"policyIdentifiers": ["1.2.3.4", "1.3.5.7"]
+		}`
+		data = CreateTemplateData("commonName", []string{"test.example.com"})
+	)
+
+	tests := []struct {
+		name    string
+		csr     *x509.CertificateRequest
+		options []Option
+		assert  func(t *testing.T, crt *x509.Certificate)
+	}{
+		{"no policies", csr, []Option{WithTemplate(DefaultLeafTemplate, data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, "commonName", crt.Subject.CommonName)
+			assert.Equal(t, []string{"test.example.com"}, crt.DNSNames)
+			assert.Empty(t, crt.PolicyIdentifiers)
+			assert.Empty(t, crt.Policies)
+		}},
+		{"policyIdentifiers", csr, []Option{WithTemplate(policyIdentifiersTemplate, data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, []asn1.ObjectIdentifier{{1, 2, 3, 4}, {1, 3, 5, 7}}, crt.PolicyIdentifiers)
+			assert.Equal(t, []x509.OID{mustOID(t, "1.2.3.4"), mustOID(t, "1.3.5.7")}, crt.Policies)
+			assertValue(t, []byte{
+				0x30, 0x0E, 0x30, 0x05, 0x06, 0x03, 0x2A, 0x03,
+				0x04, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x05, 0x07,
+			}, crt)
+		}},
+		{"extension", csr, []Option{WithTemplate(buildWithExtension(`{
+			"id": "2.5.29.32",
+			"value": {{ asn1Seq (asn1Seq (asn1Enc "oid:1.2.3.4")) (asn1Seq (asn1Enc "oid:1.3.5.7")) | toJson }}
+		}`), data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, []asn1.ObjectIdentifier{{1, 2, 3, 4}, {1, 3, 5, 7}}, crt.PolicyIdentifiers)
+			assert.Equal(t, []x509.OID{mustOID(t, "1.2.3.4"), mustOID(t, "1.3.5.7")}, crt.Policies)
+			assertValue(t, []byte{
+				0x30, 0x0E, 0x30, 0x05, 0x06, 0x03, 0x2A, 0x03,
+				0x04, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x05, 0x07,
+			}, crt)
+		}},
+		{"withCPS", csr, []Option{WithTemplate(buildWithExtension(`{
+			"id": "2.5.29.32",
+			"value": {{
+				asn1Seq
+					(asn1Seq
+						(asn1Enc "oid:1.3.5.7")
+						(asn1Seq (asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.2.1") (asn1Enc "ia5:http://example.com/cps")))
+					)
+				| toJson
+			}}
+		}`), data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, []asn1.ObjectIdentifier{{1, 3, 5, 7}}, crt.PolicyIdentifiers)
+			assert.Equal(t, []x509.OID{mustOID(t, "1.3.5.7")}, crt.Policies)
+			assertValue(t, []byte{
+				0x30, 0x2D, 0x30, 0x2B, 0x06, 0x03, 0x2B, 0x05,
+				0x07, 0x30, 0x24, 0x30, 0x22, 0x06, 0x08, 0x2B,
+				0x06, 0x01, 0x05, 0x05, 0x07, 0x02, 0x01, 0x16,
+				0x16, 0x68, 0x74, 0x74, 0x70, 0x3A, 0x2F, 0x2F,
+				0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65, 0x2E,
+				0x63, 0x6F, 0x6D, 0x2F, 0x63, 0x70, 0x73,
+			}, crt)
+		}},
+		{"withUserNotice", csr, []Option{WithTemplate(buildWithExtension(`{
+			"id": "2.5.29.32",
+			"value": {{
+				asn1Seq
+					(asn1Seq
+						(asn1Enc "oid:1.3.5.7")
+						(asn1Seq
+							(asn1Seq
+								(asn1Enc "oid:1.3.6.1.5.5.7.2.2")
+								(asn1Seq
+									(asn1Seq
+										(asn1Enc "ia5:ACME Inc.")
+										(asn1Seq (asn1Enc "int:1") (asn1Enc "int:2") (asn1Enc "int:3") (asn1Enc "int:4"))
+									)
+									(asn1Enc "ia5:The explicit text.")
+								)
+							)
+						)
+					)
+				| toJson
+			}}
+		}`), data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, []asn1.ObjectIdentifier{{1, 3, 5, 7}}, crt.PolicyIdentifiers)
+			assert.Equal(t, []x509.OID{mustOID(t, "1.3.5.7")}, crt.Policies)
+			assertValue(t, []byte{
+				0x30, 0x46, 0x30, 0x44, 0x06, 0x03, 0x2B, 0x05,
+				0x07, 0x30, 0x3D, 0x30, 0x3B, 0x06, 0x08, 0x2B,
+				0x06, 0x01, 0x05, 0x05, 0x07, 0x02, 0x02, 0x30,
+				0x2F, 0x30, 0x19, 0x16, 0x09, 0x41, 0x43, 0x4D,
+				0x45, 0x20, 0x49, 0x6E, 0x63, 0x2E, 0x30, 0x0C,
+				0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x01,
+				0x03, 0x02, 0x01, 0x04, 0x16, 0x12, 0x54, 0x68,
+				0x65, 0x20, 0x65, 0x78, 0x70, 0x6C, 0x69, 0x63,
+				0x69, 0x74, 0x20, 0x74, 0x65, 0x78, 0x74, 0x2E,
+			}, crt)
+		}},
+		{"complex", csr, []Option{WithTemplate(buildWithExtension(`{
+			"id": "2.5.29.32",
+			"value": {{
+				asn1Seq
+					(asn1Seq (asn1Enc "oid:1.2.3.4"))
+					(asn1Seq (asn1Enc "oid:1.3.5.7"))
+					(asn1Seq
+						(asn1Enc "oid:1.3.5.8")
+						(asn1Seq
+							(asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.2.1") (asn1Enc "ia5:http://example.com/cps"))
+							(asn1Seq (asn1Enc "oid:1.3.6.1.5.5.7.2.1") (asn1Enc "ia5:http://example.org/1.0/CPS"))
+							(asn1Seq
+								(asn1Enc "oid:1.3.6.1.5.5.7.2.2")
+								(asn1Seq
+									(asn1Seq
+										(asn1Enc "ia5:Organization Name")
+										(asn1Seq (asn1Enc "int:1") (asn1Enc "int:2") (asn1Enc "int:3") (asn1Enc "int:4"))
+									)
+									(asn1Enc "ia5:Explicit Text")
+								)
+							)
+						)
+					)
+				| toJson
+			}}
+		}`), data)}, func(t *testing.T, crt *x509.Certificate) {
+			assert.Equal(t, []asn1.ObjectIdentifier{{1, 2, 3, 4}, {1, 3, 5, 7}, {1, 3, 5, 8}}, crt.PolicyIdentifiers)
+			assert.Equal(t, []x509.OID{mustOID(t, "1.2.3.4"), mustOID(t, "1.3.5.7"), mustOID(t, "1.3.5.8")}, crt.Policies)
+			assertValue(t, []byte{
+				0x30, 0x81, 0xA5, 0x30, 0x05, 0x06, 0x03, 0x2A,
+				0x03, 0x04, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x05,
+				0x07, 0x30, 0x81, 0x94, 0x06, 0x03, 0x2B, 0x05,
+				0x08, 0x30, 0x81, 0x8C, 0x30, 0x22, 0x06, 0x08,
+				0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x02, 0x01,
+				0x16, 0x16, 0x68, 0x74, 0x74, 0x70, 0x3A, 0x2F,
+				0x2F, 0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65,
+				0x2E, 0x63, 0x6F, 0x6D, 0x2F, 0x63, 0x70, 0x73,
+				0x30, 0x26, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05,
+				0x05, 0x07, 0x02, 0x01, 0x16, 0x1A, 0x68, 0x74,
+				0x74, 0x70, 0x3A, 0x2F, 0x2F, 0x65, 0x78, 0x61,
+				0x6D, 0x70, 0x6C, 0x65, 0x2E, 0x6F, 0x72, 0x67,
+				0x2F, 0x31, 0x2E, 0x30, 0x2F, 0x43, 0x50, 0x53,
+				0x30, 0x3E, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05,
+				0x05, 0x07, 0x02, 0x02, 0x30, 0x32, 0x30, 0x21,
+				0x16, 0x11, 0x4F, 0x72, 0x67, 0x61, 0x6E, 0x69,
+				0x7A, 0x61, 0x74, 0x69, 0x6F, 0x6E, 0x20, 0x4E,
+				0x61, 0x6D, 0x65, 0x30, 0x0C, 0x02, 0x01, 0x01,
+				0x02, 0x01, 0x02, 0x02, 0x01, 0x03, 0x02, 0x01,
+				0x04, 0x16, 0x0D, 0x45, 0x78, 0x70, 0x6C, 0x69,
+				0x63, 0x69, 0x74, 0x20, 0x54, 0x65, 0x78, 0x74,
+			}, crt)
+		}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cert, err := NewCertificate(tt.csr, tt.options...)
+			require.NoError(t, err)
+
+			template := cert.GetCertificate()
+			crt, err := CreateCertificate(template, iss, csr.PublicKey, issPriv)
+			require.NoError(t, err)
+			tt.assert(t, crt)
+		})
+	}
+}
diff --git a/x509util/marshal_utils_test.go b/x509util/marshal_utils_test.go
index b17f1f7..598f0b5 100644
--- a/x509util/marshal_utils_test.go
+++ b/x509util/marshal_utils_test.go
@@ -7,6 +7,8 @@ import (
 	"net/url"
 	"reflect"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestMultiString_MarshalJSON(t *testing.T) {
@@ -298,3 +300,59 @@ func TestMultiObjectIdentifier_UnmarshalJSON(t *testing.T) {
 		})
 	}
 }
+
+func TestMultiOID_MarshalJSON(t *testing.T) {
+	oid1 := mustOID(t, "1.2.3.4")
+	oid2 := mustOID(t, "1.4.3.2")
+	oid3 := mustOID(t, "1.3.5.7")
+
+	tests := []struct {
+		name      string
+		m         MultiOID
+		want      []byte
+		assertion assert.ErrorAssertionFunc
+	}{
+		{"null", MultiOID(nil), []byte(`null`), assert.NoError},
+		{"empty", MultiOID{}, []byte(`[]`), assert.NoError},
+		{"one", MultiOID{oid1}, []byte(`["1.2.3.4"]`), assert.NoError},
+		{"multiple", MultiOID{oid1, oid2, oid3}, []byte(`["1.2.3.4","1.4.3.2","1.3.5.7"]`), assert.NoError},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := tt.m.MarshalJSON()
+			tt.assertion(t, err)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestMultiOID_UnmarshalJSON(t *testing.T) {
+	oid1 := mustOID(t, "1.2.3.4")
+	oid2 := mustOID(t, "1.4.3.2")
+	oid3 := mustOID(t, "1.3.5.7")
+
+	type args struct {
+		data []byte
+	}
+	tests := []struct {
+		name      string
+		args      args
+		want      MultiOID
+		assertion assert.ErrorAssertionFunc
+	}{
+		{"null", args{[]byte(`null`)}, MultiOID(nil), assert.NoError},
+		{"empty", args{[]byte(`[]`)}, MultiOID{}, assert.NoError},
+		{"one", args{[]byte(`["1.2.3.4"]`)}, MultiOID{oid1}, assert.NoError},
+		{"one string", args{[]byte(`"1.2.3.4"`)}, MultiOID{oid1}, assert.NoError},
+		{"multiple", args{[]byte(`["1.2.3.4", "1.4.3.2","1.3.5.7"]`)}, MultiOID{oid1, oid2, oid3}, assert.NoError},
+		{"fail json", args{[]byte(`["1.2.3.4",`)}, MultiOID(nil), assert.Error},
+		{"fail oid", args{[]byte(`["5.4.3.2.1"]`)}, MultiOID(nil), assert.Error},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var m MultiOID
+			tt.assertion(t, m.UnmarshalJSON(tt.args.data))
+			assert.Equal(t, tt.want, m)
+		})
+	}
+}