Skip to content

Commit

Permalink
Add support for UPN SANs
Browse files Browse the repository at this point in the history
This PR adds a new type otherName SAN UPN with OID 1.3.6.1.4.1.311.20.2.3.
This change also allows the use of the HardwareModuleName and DirectoryName
SANs in the template. The previous version only allowed the use of those using
code.
  • Loading branch information
maraino committed Oct 6, 2023
1 parent bf3d4a1 commit 11e14b3
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 5 deletions.
122 changes: 122 additions & 0 deletions x509util/certificate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,128 @@ func TestNewCertificate(t *testing.T) {
}
}

func TestNewCertificateTemplate(t *testing.T) {
marshal := func(t *testing.T, value interface{}, params string) []byte {
t.Helper()
b, err := asn1.MarshalWithParams(value, params)
assert.NoError(t, err)
return b
}

tpl := `{
"subject": {{ set (toJson .Subject | fromJson) "extraNames" (list (dict "type" "1.2.840.113556.1.4.656" "value" .Token.upn )) | toJson }},
"sans": {{ concat .SANs (list
(dict "type" "dn" "value" ` + "`" + `{"country":"US","organization":"ACME","commonName":"rocket"}` + "`" + `)
(dict "type" "permanentIdentifier" "value" .Token.pi)
(dict "type" "hardwareModuleName" "value" .Insecure.User.hmn)
(dict "type" "upn" "value" .Token.upn)
(dict "type" "1.2.3.4" "value" (printf "int:%s" .Insecure.User.id))
) | toJson }},
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
"keyUsage": ["keyEncipherment", "digitalSignature"],
{{- else }}
"keyUsage": ["digitalSignature"],
{{- end }}
"extKeyUsage": ["serverAuth", "clientAuth"],
"extensions": [
{"id": "1.2.3.4", "value": {{ asn1Enc (first .Insecure.CR.DNSNames) | toJson }}},
{"id": "1.2.3.5", "value": {{ asn1Marshal (first .Insecure.CR.DNSNames) | toJson }}},
{"id": "1.2.3.6", "value": {{ asn1Seq (asn1Enc (first .Insecure.CR.DNSNames)) (asn1Enc "int:123456") | toJson }}},
{"id": "1.2.3.7", "value": {{ asn1Set (asn1Marshal (first .Insecure.CR.DNSNames) "utf8") (asn1Enc "int:123456") | toJson }}}
]
}`

// Regular sans
sans := []string{"foo.com", "www.foo.com", "[email protected]"}
// Template data
data := CreateTemplateData("commonName", sans)
data.SetUserData(map[string]any{
"id": "123456",
"hmn": `{"type":"1.2.3.1", "serialNumber": "MTIzNDU2"}`,
})
data.SetToken(map[string]any{
"upn": "[email protected]",
"pi": "0123456789",
})

iss, issPriv := createIssuerCertificate(t, "issuer")
cr, priv := createCertificateRequest(t, "commonName", sans)

cert, err := NewCertificate(cr, WithTemplate(tpl, data))
require.NoError(t, err)

crt, err := CreateCertificate(cert.GetCertificate(), iss, priv.Public(), issPriv)
require.NoError(t, err)

// Create expected subject
assert.Equal(t, pkix.Name{
CommonName: "commonName",
Names: []pkix.AttributeTypeAndValue{
{Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: "commonName"},
{Type: asn1.ObjectIdentifier{1, 2, 840, 113556, 1, 4, 656}, Value: "[email protected]"},
},
}, crt.Subject)

// Create expected SAN extension
var rawValues []asn1.RawValue
for _, san := range []SubjectAlternativeName{
{Type: DNSType, Value: "foo.com"},
{Type: DNSType, Value: "www.foo.com"},
{Type: EmailType, Value: "[email protected]"},
{Type: DirectoryNameType, ASN1Value: []byte(`{"country":"US","organization":"ACME","commonName":"rocket"}`)},
{Type: PermanentIdentifierType, Value: "0123456789"},
{Type: HardwareModuleNameType, ASN1Value: []byte(`{"type":"1.2.3.1", "serialNumber": "MTIzNDU2"}`)},
{Type: UPNType, Value: "[email protected]"},
{Type: "1.2.3.4", Value: "int:123456"},
} {
rawValue, err := san.RawValue()
require.NoError(t, err)
rawValues = append(rawValues, rawValue)
}
rawBytes, err := asn1.Marshal(rawValues)
require.NoError(t, err)

var found int
for _, ext := range crt.Extensions {
switch {
case ext.Id.Equal(oidExtensionSubjectAltName):
assert.Equal(t, pkix.Extension{
Id: oidExtensionSubjectAltName,
Value: rawBytes,
}, ext)
case ext.Id.Equal([]int{1, 2, 3, 4}):
assert.Equal(t, pkix.Extension{
Id: ext.Id,
Value: marshal(t, "foo.com", "printable"),
}, ext)
case ext.Id.Equal([]int{1, 2, 3, 5}):
assert.Equal(t, pkix.Extension{
Id: ext.Id,
Value: marshal(t, "foo.com", ""),
}, ext)
case ext.Id.Equal([]int{1, 2, 3, 6}):
assert.Equal(t, pkix.Extension{
Id: ext.Id,
Value: marshal(t, []any{"foo.com", 123456}, ""),
}, ext)
case ext.Id.Equal([]int{1, 2, 3, 7}):
assert.Equal(t, pkix.Extension{
Id: ext.Id,
Value: marshal(t, struct {
String string `asn1:"utf8"`
Int int
}{"foo.com", 123456}, "set"),
}, ext)
default:
continue
}
found++
}

assert.Equal(t, 5, found, "some of the expected extension where not found")

}

func TestNewCertificateFromX509(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
Expand Down
49 changes: 44 additions & 5 deletions x509util/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const (
RegisteredIDType = "registeredID"
PermanentIdentifierType = "permanentIdentifier"
HardwareModuleNameType = "hardwareModuleName"
UPNType = "upn"
)

//nolint:deadcode // ignore
Expand All @@ -87,6 +88,16 @@ const (
// provided.
const sanTypeSeparator = ":"

// User Principal Name or UPN is a subject alternative name used for smart card
// logon. This OID is associated with Microsoft cryptography and has the
// internal name of szOID_NT_PRINCIPAL_NAME.
//
// The UPN is defined in Microsoft Open Specifications and Windows client
// documentation for IT Pros:
// - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/ea9ef420-4cbf-44bc-b093-c4175139f90f
// - https://learn.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-certificate-requirements-and-enumeration
var oidUserPrincipalName = []int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}

// RFC 4043 - https://datatracker.ietf.org/doc/html/rfc4043
var oidPermanentIdentifier = []int{1, 3, 6, 1, 5, 5, 7, 8, 3}

Expand Down Expand Up @@ -328,7 +339,7 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
return asn1.RawValue{Tag: nameTypeIP, Class: asn1.ClassContextSpecific, Bytes: ip}, nil
case RegisteredIDType:
if s.Value == "" {
return zero, errors.New("error parsing RegisteredID SAN: blank value is not allowed")
return zero, errors.New("error parsing RegisteredID SAN: empty value is not allowed")
}
oid, err := parseObjectIdentifier(s.Value)
if err != nil {
Expand Down Expand Up @@ -356,11 +367,17 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
}
return otherName, nil
case HardwareModuleNameType:
if len(s.ASN1Value) == 0 {
var data []byte
switch {
case len(s.ASN1Value) != 0:
data = s.ASN1Value
case len(s.Value) != 0:

Check failure on line 374 in x509util/extensions.go

View workflow job for this annotation

GitHub Actions / ci / lint / lint

emptyStringTest: replace `len(s.Value) != 0` with `s.Value != ""` (gocritic)
data = []byte(s.Value)
default:
return zero, errors.New("error parsing HardwareModuleName SAN: empty asn1Value is not allowed")
}
var v HardwareModuleName
if err := json.Unmarshal(s.ASN1Value, &v); err != nil {
if err := json.Unmarshal(data, &v); err != nil {
return zero, errors.Wrap(err, "error unmarshaling HardwareModuleName SAN")
}
otherName, err := marshalOtherName(oidHardwareModuleNameIdentifier, v.asn1Type())
Expand All @@ -369,11 +386,17 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
}
return otherName, nil
case DirectoryNameType:
if len(s.ASN1Value) == 0 {
var data []byte
switch {
case len(s.ASN1Value) != 0:
data = s.ASN1Value
case len(s.Value) != 0:

Check failure on line 393 in x509util/extensions.go

View workflow job for this annotation

GitHub Actions / ci / lint / lint

emptyStringTest: replace `len(s.Value) != 0` with `s.Value != ""` (gocritic)
data = []byte(s.Value)
default:
return zero, errors.New("error parsing DirectoryName SAN: empty asn1Value is not allowed")
}
var dn Name
if err := json.Unmarshal(s.ASN1Value, &dn); err != nil {
if err := json.Unmarshal(data, &dn); err != nil {
return zero, errors.Wrap(err, "error unmarshaling DirectoryName SAN")
}
rdn, err := asn1.Marshal(dn.goValue().ToRDNSequence())
Expand All @@ -389,6 +412,22 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
IsCompound: true,
Bytes: rdn,
}, nil
case UPNType:
if len(s.Value) == 0 {

Check failure on line 416 in x509util/extensions.go

View workflow job for this annotation

GitHub Actions / ci / lint / lint

emptyStringTest: replace `len(s.Value) == 0` with `s.Value == ""` (gocritic)
return zero, errors.New("error parsing UserPrincipalName SAN: empty Value is not allowed")
}
rawBytes, err := marshalExplicitValue(s.Value, "utf8")
if err != nil {
return zero, errors.Wrapf(err, "error marshaling ASN1 value %q", s.Value)
}
upnBytes, err := asn1.MarshalWithParams(otherName{
TypeID: oidUserPrincipalName,
Value: asn1.RawValue{FullBytes: rawBytes},
}, "tag:0")
if err != nil {
return zero, errors.Wrap(err, "unable to Marshal UserPrincipalName SAN")
}

Check warning on line 429 in x509util/extensions.go

View check run for this annotation

Codecov / codecov/patch

x509util/extensions.go#L428-L429

Added lines #L428 - L429 were not covered by tests
return asn1.RawValue{FullBytes: upnBytes}, nil
case X400AddressType, EDIPartyNameType:
return zero, fmt.Errorf("unimplemented SAN type %s", s.Type)
default:
Expand Down
5 changes: 5 additions & 0 deletions x509util/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ func TestSubjectAlternativeName_RawValue(t *testing.T) {
{49, 15, 48, 13, 6, 3, 85, 4, 3, asn1.TagPrintableString, 6}, []byte("rocket"),
}, nil),
}, false},
{"userPrincipalName", fields{"upn", "[email protected]", nil}, asn1.RawValue{
FullBytes: []byte{160, 27, 6, 10, 43, 6, 1, 4, 1, 130, 55, 20, 2, 3, 160, 13, 12, 11, 102, 111, 111, 64, 98, 97, 114, 46, 99, 111, 109},
}, false},
{"otherName int", fields{"1.2.3.4", "int:1024", nil}, asn1.RawValue{
FullBytes: []byte{160, 11, 6, 3, 42, 3, 4, 160, 4, 2, 2, 4, 0},
}, false},
Expand Down Expand Up @@ -389,6 +392,8 @@ func TestSubjectAlternativeName_RawValue(t *testing.T) {
{"fail registeredID", fields{"registeredID", "4.3.2.1", nil}, asn1.RawValue{}, true},
{"fail registeredID empty", fields{"registeredID", "", nil}, asn1.RawValue{}, true},
{"fail registeredID parse", fields{"registeredID", "a.b.c.d", nil}, asn1.RawValue{}, true},
{"fail upn empty", fields{"upn", "", nil}, asn1.RawValue{}, true},
{"fail upn value", fields{"upn", "foo\xff@mail.com", nil}, asn1.RawValue{}, true},
{"fail otherName parse", fields{"a.b.c.d", "foo", nil}, asn1.RawValue{}, true},
{"fail otherName marshal", fields{"1", "foo", nil}, asn1.RawValue{}, true},
{"fail otherName int", fields{"1.2.3.4", "int:abc", nil}, asn1.RawValue{}, true},
Expand Down

0 comments on commit 11e14b3

Please sign in to comment.