Skip to content

Commit 11e14b3

Browse files
committed
Add support for UPN SANs
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.
1 parent bf3d4a1 commit 11e14b3

File tree

3 files changed

+171
-5
lines changed

3 files changed

+171
-5
lines changed

x509util/certificate_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,128 @@ func TestNewCertificate(t *testing.T) {
292292
}
293293
}
294294

295+
func TestNewCertificateTemplate(t *testing.T) {
296+
marshal := func(t *testing.T, value interface{}, params string) []byte {
297+
t.Helper()
298+
b, err := asn1.MarshalWithParams(value, params)
299+
assert.NoError(t, err)
300+
return b
301+
}
302+
303+
tpl := `{
304+
"subject": {{ set (toJson .Subject | fromJson) "extraNames" (list (dict "type" "1.2.840.113556.1.4.656" "value" .Token.upn )) | toJson }},
305+
"sans": {{ concat .SANs (list
306+
(dict "type" "dn" "value" ` + "`" + `{"country":"US","organization":"ACME","commonName":"rocket"}` + "`" + `)
307+
(dict "type" "permanentIdentifier" "value" .Token.pi)
308+
(dict "type" "hardwareModuleName" "value" .Insecure.User.hmn)
309+
(dict "type" "upn" "value" .Token.upn)
310+
(dict "type" "1.2.3.4" "value" (printf "int:%s" .Insecure.User.id))
311+
) | toJson }},
312+
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
313+
"keyUsage": ["keyEncipherment", "digitalSignature"],
314+
{{- else }}
315+
"keyUsage": ["digitalSignature"],
316+
{{- end }}
317+
"extKeyUsage": ["serverAuth", "clientAuth"],
318+
"extensions": [
319+
{"id": "1.2.3.4", "value": {{ asn1Enc (first .Insecure.CR.DNSNames) | toJson }}},
320+
{"id": "1.2.3.5", "value": {{ asn1Marshal (first .Insecure.CR.DNSNames) | toJson }}},
321+
{"id": "1.2.3.6", "value": {{ asn1Seq (asn1Enc (first .Insecure.CR.DNSNames)) (asn1Enc "int:123456") | toJson }}},
322+
{"id": "1.2.3.7", "value": {{ asn1Set (asn1Marshal (first .Insecure.CR.DNSNames) "utf8") (asn1Enc "int:123456") | toJson }}}
323+
]
324+
}`
325+
326+
// Regular sans
327+
sans := []string{"foo.com", "www.foo.com", "root@foo.com"}
328+
// Template data
329+
data := CreateTemplateData("commonName", sans)
330+
data.SetUserData(map[string]any{
331+
"id": "123456",
332+
"hmn": `{"type":"1.2.3.1", "serialNumber": "MTIzNDU2"}`,
333+
})
334+
data.SetToken(map[string]any{
335+
"upn": "foo@upn.com",
336+
"pi": "0123456789",
337+
})
338+
339+
iss, issPriv := createIssuerCertificate(t, "issuer")
340+
cr, priv := createCertificateRequest(t, "commonName", sans)
341+
342+
cert, err := NewCertificate(cr, WithTemplate(tpl, data))
343+
require.NoError(t, err)
344+
345+
crt, err := CreateCertificate(cert.GetCertificate(), iss, priv.Public(), issPriv)
346+
require.NoError(t, err)
347+
348+
// Create expected subject
349+
assert.Equal(t, pkix.Name{
350+
CommonName: "commonName",
351+
Names: []pkix.AttributeTypeAndValue{
352+
{Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: "commonName"},
353+
{Type: asn1.ObjectIdentifier{1, 2, 840, 113556, 1, 4, 656}, Value: "foo@upn.com"},
354+
},
355+
}, crt.Subject)
356+
357+
// Create expected SAN extension
358+
var rawValues []asn1.RawValue
359+
for _, san := range []SubjectAlternativeName{
360+
{Type: DNSType, Value: "foo.com"},
361+
{Type: DNSType, Value: "www.foo.com"},
362+
{Type: EmailType, Value: "root@foo.com"},
363+
{Type: DirectoryNameType, ASN1Value: []byte(`{"country":"US","organization":"ACME","commonName":"rocket"}`)},
364+
{Type: PermanentIdentifierType, Value: "0123456789"},
365+
{Type: HardwareModuleNameType, ASN1Value: []byte(`{"type":"1.2.3.1", "serialNumber": "MTIzNDU2"}`)},
366+
{Type: UPNType, Value: "foo@upn.com"},
367+
{Type: "1.2.3.4", Value: "int:123456"},
368+
} {
369+
rawValue, err := san.RawValue()
370+
require.NoError(t, err)
371+
rawValues = append(rawValues, rawValue)
372+
}
373+
rawBytes, err := asn1.Marshal(rawValues)
374+
require.NoError(t, err)
375+
376+
var found int
377+
for _, ext := range crt.Extensions {
378+
switch {
379+
case ext.Id.Equal(oidExtensionSubjectAltName):
380+
assert.Equal(t, pkix.Extension{
381+
Id: oidExtensionSubjectAltName,
382+
Value: rawBytes,
383+
}, ext)
384+
case ext.Id.Equal([]int{1, 2, 3, 4}):
385+
assert.Equal(t, pkix.Extension{
386+
Id: ext.Id,
387+
Value: marshal(t, "foo.com", "printable"),
388+
}, ext)
389+
case ext.Id.Equal([]int{1, 2, 3, 5}):
390+
assert.Equal(t, pkix.Extension{
391+
Id: ext.Id,
392+
Value: marshal(t, "foo.com", ""),
393+
}, ext)
394+
case ext.Id.Equal([]int{1, 2, 3, 6}):
395+
assert.Equal(t, pkix.Extension{
396+
Id: ext.Id,
397+
Value: marshal(t, []any{"foo.com", 123456}, ""),
398+
}, ext)
399+
case ext.Id.Equal([]int{1, 2, 3, 7}):
400+
assert.Equal(t, pkix.Extension{
401+
Id: ext.Id,
402+
Value: marshal(t, struct {
403+
String string `asn1:"utf8"`
404+
Int int
405+
}{"foo.com", 123456}, "set"),
406+
}, ext)
407+
default:
408+
continue
409+
}
410+
found++
411+
}
412+
413+
assert.Equal(t, 5, found, "some of the expected extension where not found")
414+
415+
}
416+
295417
func TestNewCertificateFromX509(t *testing.T) {
296418
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
297419
require.NoError(t, err)

x509util/extensions.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const (
6666
RegisteredIDType = "registeredID"
6767
PermanentIdentifierType = "permanentIdentifier"
6868
HardwareModuleNameType = "hardwareModuleName"
69+
UPNType = "upn"
6970
)
7071

7172
//nolint:deadcode // ignore
@@ -87,6 +88,16 @@ const (
8788
// provided.
8889
const sanTypeSeparator = ":"
8990

91+
// User Principal Name or UPN is a subject alternative name used for smart card
92+
// logon. This OID is associated with Microsoft cryptography and has the
93+
// internal name of szOID_NT_PRINCIPAL_NAME.
94+
//
95+
// The UPN is defined in Microsoft Open Specifications and Windows client
96+
// documentation for IT Pros:
97+
// - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/ea9ef420-4cbf-44bc-b093-c4175139f90f
98+
// - https://learn.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-certificate-requirements-and-enumeration
99+
var oidUserPrincipalName = []int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}
100+
90101
// RFC 4043 - https://datatracker.ietf.org/doc/html/rfc4043
91102
var oidPermanentIdentifier = []int{1, 3, 6, 1, 5, 5, 7, 8, 3}
92103

@@ -328,7 +339,7 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
328339
return asn1.RawValue{Tag: nameTypeIP, Class: asn1.ClassContextSpecific, Bytes: ip}, nil
329340
case RegisteredIDType:
330341
if s.Value == "" {
331-
return zero, errors.New("error parsing RegisteredID SAN: blank value is not allowed")
342+
return zero, errors.New("error parsing RegisteredID SAN: empty value is not allowed")
332343
}
333344
oid, err := parseObjectIdentifier(s.Value)
334345
if err != nil {
@@ -356,11 +367,17 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
356367
}
357368
return otherName, nil
358369
case HardwareModuleNameType:
359-
if len(s.ASN1Value) == 0 {
370+
var data []byte
371+
switch {
372+
case len(s.ASN1Value) != 0:
373+
data = s.ASN1Value
374+
case len(s.Value) != 0:
375+
data = []byte(s.Value)
376+
default:
360377
return zero, errors.New("error parsing HardwareModuleName SAN: empty asn1Value is not allowed")
361378
}
362379
var v HardwareModuleName
363-
if err := json.Unmarshal(s.ASN1Value, &v); err != nil {
380+
if err := json.Unmarshal(data, &v); err != nil {
364381
return zero, errors.Wrap(err, "error unmarshaling HardwareModuleName SAN")
365382
}
366383
otherName, err := marshalOtherName(oidHardwareModuleNameIdentifier, v.asn1Type())
@@ -369,11 +386,17 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
369386
}
370387
return otherName, nil
371388
case DirectoryNameType:
372-
if len(s.ASN1Value) == 0 {
389+
var data []byte
390+
switch {
391+
case len(s.ASN1Value) != 0:
392+
data = s.ASN1Value
393+
case len(s.Value) != 0:
394+
data = []byte(s.Value)
395+
default:
373396
return zero, errors.New("error parsing DirectoryName SAN: empty asn1Value is not allowed")
374397
}
375398
var dn Name
376-
if err := json.Unmarshal(s.ASN1Value, &dn); err != nil {
399+
if err := json.Unmarshal(data, &dn); err != nil {
377400
return zero, errors.Wrap(err, "error unmarshaling DirectoryName SAN")
378401
}
379402
rdn, err := asn1.Marshal(dn.goValue().ToRDNSequence())
@@ -389,6 +412,22 @@ func (s SubjectAlternativeName) RawValue() (asn1.RawValue, error) {
389412
IsCompound: true,
390413
Bytes: rdn,
391414
}, nil
415+
case UPNType:
416+
if len(s.Value) == 0 {
417+
return zero, errors.New("error parsing UserPrincipalName SAN: empty Value is not allowed")
418+
}
419+
rawBytes, err := marshalExplicitValue(s.Value, "utf8")
420+
if err != nil {
421+
return zero, errors.Wrapf(err, "error marshaling ASN1 value %q", s.Value)
422+
}
423+
upnBytes, err := asn1.MarshalWithParams(otherName{
424+
TypeID: oidUserPrincipalName,
425+
Value: asn1.RawValue{FullBytes: rawBytes},
426+
}, "tag:0")
427+
if err != nil {
428+
return zero, errors.Wrap(err, "unable to Marshal UserPrincipalName SAN")
429+
}
430+
return asn1.RawValue{FullBytes: upnBytes}, nil
392431
case X400AddressType, EDIPartyNameType:
393432
return zero, fmt.Errorf("unimplemented SAN type %s", s.Type)
394433
default:

x509util/extensions_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ func TestSubjectAlternativeName_RawValue(t *testing.T) {
331331
{49, 15, 48, 13, 6, 3, 85, 4, 3, asn1.TagPrintableString, 6}, []byte("rocket"),
332332
}, nil),
333333
}, false},
334+
{"userPrincipalName", fields{"upn", "foo@bar.com", nil}, asn1.RawValue{
335+
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},
336+
}, false},
334337
{"otherName int", fields{"1.2.3.4", "int:1024", nil}, asn1.RawValue{
335338
FullBytes: []byte{160, 11, 6, 3, 42, 3, 4, 160, 4, 2, 2, 4, 0},
336339
}, false},
@@ -389,6 +392,8 @@ func TestSubjectAlternativeName_RawValue(t *testing.T) {
389392
{"fail registeredID", fields{"registeredID", "4.3.2.1", nil}, asn1.RawValue{}, true},
390393
{"fail registeredID empty", fields{"registeredID", "", nil}, asn1.RawValue{}, true},
391394
{"fail registeredID parse", fields{"registeredID", "a.b.c.d", nil}, asn1.RawValue{}, true},
395+
{"fail upn empty", fields{"upn", "", nil}, asn1.RawValue{}, true},
396+
{"fail upn value", fields{"upn", "foo\xff@mail.com", nil}, asn1.RawValue{}, true},
392397
{"fail otherName parse", fields{"a.b.c.d", "foo", nil}, asn1.RawValue{}, true},
393398
{"fail otherName marshal", fields{"1", "foo", nil}, asn1.RawValue{}, true},
394399
{"fail otherName int", fields{"1.2.3.4", "int:abc", nil}, asn1.RawValue{}, true},

0 commit comments

Comments
 (0)