Repository: smallstep/crypto
Pull request #209 (2023-04-03 13:31:32)

Add `ParseSubjectAlternativeNames` to `x509util`


Patch:
diff --git a/x509util/extensions.go b/x509util/extensions.go
index c94289b..5292774 100644
--- a/x509util/extensions.go
+++ b/x509util/extensions.go
@@ -1008,3 +1008,136 @@ func createSubjectAltNameExtension(dnsNames, emailAddresses MultiString, ipAddre
 		Value:    rawBytes,
 	}, nil
 }
+
+type SubjectAlternativeNames struct {
+	DNSNames             []string
+	EmailAddresses       []string
+	IPAddresses          []net.IP
+	URIs                 []*url.URL
+	PermanentIdentifiers []PermanentIdentifier
+	HardwareModuleNames  []HardwareModuleName
+	//OtherNames          []OtherName // TODO(hs): unused at the moment; do we need it? what type definition to use?
+}
+
+// ParseSubjectAlternativeNames parses the Subject Alternative Names
+// from the X.509 certificate `c`. SAN types supported by the Go stdlib,
+// including DNS names, IP addresses, email addresses and URLs, are copied
+// to the result first. After that, the raw extension bytes are parsed to
+// extract PermanentIdentifiers and HardwareModuleNames SANs.
+func ParseSubjectAlternativeNames(c *x509.Certificate) (sans SubjectAlternativeNames, err error) {
+	// the Certificate c is expected to have been processed before, so the
+	// SANs known by the Go stdlib are expected to have been populated already.
+	// These SANs are copied over to the result.
+	sans.DNSNames = c.DNSNames
+	sans.IPAddresses = c.IPAddresses
+	sans.EmailAddresses = c.EmailAddresses
+	sans.URIs = c.URIs
+
+	var sanExtension pkix.Extension
+	for _, ext := range c.Extensions {
+		if ext.Id.Equal(oidExtensionSubjectAltName) {
+			sanExtension = ext
+			break
+		}
+	}
+
+	if sanExtension.Value == nil {
+		return
+	}
+
+	_, otherNames, err := parseSubjectAltName(sanExtension)
+	if err != nil {
+		return sans, fmt.Errorf("failed parsing SubjectAltName extension: %w", err)
+	}
+
+	for _, otherName := range otherNames {
+		switch {
+		case otherName.TypeID.Equal(oidPermanentIdentifier):
+			permanentIdentifier, err := parsePermanentIdentifier(otherName.Value.FullBytes)
+			if err != nil {
+				return sans, fmt.Errorf("failed parsing PermanentIdentifier: %w", err)
+			}
+			sans.PermanentIdentifiers = append(sans.PermanentIdentifiers, permanentIdentifier)
+		case otherName.TypeID.Equal(oidHardwareModuleNameIdentifier):
+			hardwareModuleName, err := parseHardwareModuleName(otherName.Value.FullBytes)
+			if err != nil {
+				return sans, fmt.Errorf("failed parsing HardwareModuleName: %w", err)
+			}
+			sans.HardwareModuleNames = append(sans.HardwareModuleNames, hardwareModuleName)
+		default:
+			// TODO(hs): handle other types; defaulting to otherName?
+		}
+	}
+	return
+}
+
+// https://datatracker.ietf.org/doc/html/rfc5280#page-35
+func parseSubjectAltName(ext pkix.Extension) (dirNames []pkix.Name, otherNames []otherName, err error) {
+	err = forEachSAN(ext.Value, func(generalName asn1.RawValue) error {
+		switch generalName.Tag {
+		case 0: // otherName
+			var on otherName
+			if _, err := asn1.UnmarshalWithParams(generalName.FullBytes, &on, "tag:0"); err != nil {
+				return fmt.Errorf("failed unmarshaling otherName: %w", err)
+			}
+			otherNames = append(otherNames, on)
+		case 4: // directoryName
+			var rdns pkix.RDNSequence
+			if _, err := asn1.Unmarshal(generalName.Bytes, &rdns); err != nil {
+				return fmt.Errorf("failed unmarshaling directoryName: %w", err)
+			}
+			var dirName pkix.Name
+			dirName.FillFromRDNSequence(&rdns)
+			dirNames = append(dirNames, dirName)
+		default:
+			// skipping the other tag values intentionally
+		}
+		return nil
+	})
+	return
+}
+
+func parsePermanentIdentifier(der []byte) (PermanentIdentifier, error) {
+	var permID asn1PermanentIdentifier
+	if _, err := asn1.UnmarshalWithParams(der, &permID, "explicit,tag:0"); err != nil {
+		return PermanentIdentifier{}, fmt.Errorf("failed unmarshaling der data: %w", err)
+	}
+	return PermanentIdentifier{Identifier: permID.IdentifierValue, Assigner: ObjectIdentifier(permID.Assigner)}, nil
+}
+
+func parseHardwareModuleName(der []byte) (HardwareModuleName, error) {
+	var hardwareModuleName asn1HardwareModuleName
+	if _, err := asn1.UnmarshalWithParams(der, &hardwareModuleName, "explicit,tag:0"); err != nil {
+		return HardwareModuleName{}, fmt.Errorf("failed unmarshaling der data: %w", err)
+	}
+	return HardwareModuleName{Type: ObjectIdentifier(hardwareModuleName.Type), SerialNumber: hardwareModuleName.SerialNumber}, nil
+}
+
+// Borrowed from the x509 package.
+func forEachSAN(extension []byte, callback func(ext asn1.RawValue) error) error {
+	var seq asn1.RawValue
+	rest, err := asn1.Unmarshal(extension, &seq)
+	if err != nil {
+		return err
+	} else if len(rest) != 0 {
+		return errors.New("x509: trailing data after X.509 extension")
+	}
+	if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 {
+		return asn1.StructuralError{Msg: "bad SAN sequence"}
+	}
+
+	rest = seq.Bytes
+	for len(rest) > 0 {
+		var v asn1.RawValue
+		rest, err = asn1.Unmarshal(rest, &v)
+		if err != nil {
+			return err
+		}
+
+		if err := callback(v); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}


Test patch:
diff --git a/x509util/extensions_test.go b/x509util/extensions_test.go
index 6eb7732..bf49de2 100644
--- a/x509util/extensions_test.go
+++ b/x509util/extensions_test.go
@@ -11,6 +11,9 @@ import (
 	"net/url"
 	"reflect"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func Test_convertName(t *testing.T) {
@@ -1355,3 +1358,104 @@ func Test_createSubjectAltNameExtension(t *testing.T) {
 		})
 	}
 }
+
+func mustParseURL(t *testing.T, s string) *url.URL {
+	t.Helper()
+	u, err := url.Parse(s)
+	require.NoError(t, err)
+	return u
+}
+
+func TestParseSubjectAlternativeNames(t *testing.T) {
+	permanentIdentifierSAN := SubjectAlternativeName{
+		Type:  PermanentIdentifierType,
+		Value: "test-permanent-identifier",
+	}
+	permanentIdentifierSANExtension, err := createSubjectAltNameExtension(nil, nil, nil, nil, []SubjectAlternativeName{permanentIdentifierSAN}, true)
+	require.NoError(t, err)
+	hardwareModuleNameSAN := SubjectAlternativeName{
+		Type:      HardwareModuleNameType,
+		ASN1Value: []byte(`{"type": "1.2.3.4", "serialNumber": "MTIzNDU2Nzg="}`),
+	}
+	hardwareModuleNameSANExtension, err := createSubjectAltNameExtension(nil, nil, nil, nil, []SubjectAlternativeName{hardwareModuleNameSAN}, true)
+	require.NoError(t, err)
+	tests := []struct {
+		name     string
+		cert     *x509.Certificate
+		wantSans SubjectAlternativeNames
+		expErr   error
+	}{
+		{
+			name: "ok/stdlib",
+			cert: &x509.Certificate{
+				DNSNames:       []string{"example.com"},
+				IPAddresses:    []net.IP{net.ParseIP("127.0.0.1")},
+				EmailAddresses: []string{"test@example.com"},
+				URIs:           []*url.URL{mustParseURL(t, "https://127.0.0.1")},
+			},
+			wantSans: SubjectAlternativeNames{
+				DNSNames:       []string{"example.com"},
+				IPAddresses:    []net.IP{net.ParseIP("127.0.0.1")},
+				EmailAddresses: []string{"test@example.com"},
+				URIs:           []*url.URL{mustParseURL(t, "https://127.0.0.1")},
+			},
+		},
+		{
+			name: "ok/permanent-identifier",
+			cert: &x509.Certificate{
+				DNSNames: []string{"example.com"},
+				Extensions: []pkix.Extension{
+					{
+						Id:       asn1.ObjectIdentifier(permanentIdentifierSANExtension.ID),
+						Critical: permanentIdentifierSANExtension.Critical,
+						Value:    permanentIdentifierSANExtension.Value,
+					},
+				},
+			},
+			wantSans: SubjectAlternativeNames{
+				DNSNames: []string{"example.com"},
+				PermanentIdentifiers: []PermanentIdentifier{
+					{
+						Identifier: "test-permanent-identifier",
+					},
+				},
+			},
+		},
+		{
+			name: "ok/hardware-module-name",
+			cert: &x509.Certificate{
+				DNSNames: []string{"example.com"},
+				Extensions: []pkix.Extension{
+					{
+						Id:       asn1.ObjectIdentifier(hardwareModuleNameSANExtension.ID),
+						Critical: hardwareModuleNameSANExtension.Critical,
+						Value:    hardwareModuleNameSANExtension.Value,
+					},
+				},
+			},
+			wantSans: SubjectAlternativeNames{
+				DNSNames: []string{"example.com"},
+				HardwareModuleNames: []HardwareModuleName{
+					{
+						Type:         ObjectIdentifier([]int{1, 2, 3, 4}),
+						SerialNumber: []byte("12345678"),
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotSans, err := ParseSubjectAlternativeNames(tt.cert)
+			if tt.expErr != nil {
+				if assert.Error(t, err) {
+					assert.EqualError(t, err, tt.expErr.Error())
+				}
+				assert.Empty(t, gotSans)
+			}
+
+			assert.NoError(t, err)
+			assert.Equal(t, tt.wantSans, gotSans)
+		})
+	}
+}