Skip to content

Commit

Permalink
Add CA Search functionality for the windows cert store (#5115)
Browse files Browse the repository at this point in the history
This PR adds a new field for searching the windows Certificate Store for
CA certificates.

When specifying a value in the `ca_certs_match` field, the following
locations are searched:
- Trusted Root Certification Authorities
- Third-Party Root Certification Authorities
- Intermediate Certification Authorities

Based on user input, the first matching certificate from subject in the
list, from each location, is added to the certificate pool. If no
certificates can be found an error is reported.

Example usage:

```text
tls {
  cert_store: "WindowsLocalMachine"
  cert_match_by: "Subject"
  cert_match: "client001"
  ca_certs_match: ["CA1","CA2"]
  timeout: 3s
}
```

Additional Notes:
Test scripts have been added to add and remove the test CA certificate,
and existing windows certificates have been updated as they were
expired.


Signed-off-by: Colin Sullivan <colin@luxantsolutions.com>
  • Loading branch information
derekcollison authored Mar 13, 2024
2 parents 9fa359a + 6e768e8 commit f1cd3ed
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 27 deletions.
6 changes: 3 additions & 3 deletions server/certstore/certstore_other.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2022-2023 The NATS Authors
// Copyright 2022-2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
Expand Down Expand Up @@ -26,8 +26,8 @@ var _ = MATCHBYEMPTY
// otherKey implements crypto.Signer and crypto.Decrypter to satisfy linter on platforms that don't implement certstore
type otherKey struct{}

func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, config *tls.Config) error {
_, _, _, _ = certStore, certMatchBy, certMatch, config
func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, caCertsMatch []string, config *tls.Config) error {
_, _, _, _, _ = certStore, certMatchBy, certMatch, caCertsMatch, config
return ErrOSNotCompatCertStore
}

Expand Down
101 changes: 95 additions & 6 deletions server/certstore/certstore_windows.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2022-2023 The NATS Authors
// Copyright 2022-2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
Expand Down Expand Up @@ -111,8 +111,17 @@ var (
crypto.SHA512: winWide("SHA512"), // BCRYPT_SHA512_ALGORITHM
}

// MY is well-known system store on Windows that holds personal certificates
winMyStore = winWide("MY")
// MY is well-known system store on Windows that holds personal certificates. Read
// More about the CA locations here:
// https://learn.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/wcf/certificate-of-clientcertificate-element?redirectedfrom=MSDN
// https://superuser.com/questions/217719/what-are-the-windows-system-certificate-stores
// https://docs.microsoft.com/en-us/windows/win32/seccrypto/certificate-stores
// https://learn.microsoft.com/en-us/windows/win32/seccrypto/system-store-locations
// https://stackoverflow.com/questions/63286085/which-x509-storename-refers-to-the-certificates-stored-beneath-trusted-root-cert#:~:text=4-,StoreName.,is%20%22Intermediate%20Certification%20Authorities%22.
winMyStore = winWide("MY")
winIntermediateCAStore = winWide("CA")
winRootStore = winWide("Root")
winAuthRootStore = winWide("AuthRoot")

// These DLLs must be available on all Windows hosts
winCrypt32 = windows.MustLoadDLL("crypt32.dll")
Expand All @@ -137,9 +146,40 @@ type winPSSPaddingInfo struct {
cbSalt uint32
}

// TLSConfig fulfills the same function as reading cert and key pair from pem files but
// sources the Windows certificate store instead
func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, config *tls.Config) error {
// createCACertsPool generates a CertPool from the Windows certificate store,
// adding all matching certificates from the caCertsMatch array to the pool.
// All matching certificates (vs first) are added to the pool based on a user
// request. If no certificates are found an error is returned.
func createCACertsPool(cs *winCertStore, storeType uint32, caCertsMatch []string) (*x509.CertPool, error) {
var errs []error
caPool := x509.NewCertPool()
for _, s := range caCertsMatch {
lfs, err := cs.caCertsBySubjectMatch(s, storeType)
if err != nil {
errs = append(errs, err)
} else {
for _, lf := range lfs {
caPool.AddCert(lf)
}
}
}
// If every lookup failed return the errors.
if len(errs) == len(caCertsMatch) {
return nil, fmt.Errorf("unable to match any CA certificate: %v", errs)
}
return caPool, nil
}

// TLSConfig fulfills the same function as reading cert and key pair from
// pem files but sources the Windows certificate store instead. The
// certMatchBy and certMatch fields search the "MY" certificate location
// for the first certificate that matches the certMatch field. The
// caCertsMatch field is used to search the Trusted Root, Third Party Root,
// and Intermediate Certificate Authority locations for certificates with
// Subjects matching the provided strings. If a match is found, the
// certificate is added to the pool that is used to verify the certificate
// chain.
func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, caCertsMatch []string, config *tls.Config) error {
var (
leaf *x509.Certificate
leafCtx *windows.CertContext
Expand Down Expand Up @@ -186,6 +226,14 @@ func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, c
if pk == nil {
return ErrNoPrivateKeyStoreRef
}
// Look for CA Certificates
if len(caCertsMatch) != 0 {
caPool, err := createCACertsPool(cs, scope, caCertsMatch)
if err != nil {
return err
}
config.ClientCAs = caPool
}
} else {
return ErrBadCertStore
}
Expand Down Expand Up @@ -319,6 +367,47 @@ func (w *winCertStore) certBySubject(subject string, storeType uint32) (*x509.Ce
return w.certSearch(winFindSubjectStr, subject, winMyStore, storeType)
}

// caCertBySubject matches and returns all matching certificates of the subject field.
//
// The following locations are searched:
// 1) Root (Trusted Root Certification Authorities)
// 2) AuthRoot (Third-Party Root Certification Authorities)
// 3) CA (Intermediate Certification Authorities)
//
// Caller specifies current user's personal certs or local machine's personal certs using storeType.
// See CERT_FIND_SUBJECT_STR description at https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfindcertificateinstore
func (w *winCertStore) caCertsBySubjectMatch(subject string, storeType uint32) ([]*x509.Certificate, error) {
var (
leaf *x509.Certificate
searchLocations = [3]*uint16{winRootStore, winAuthRootStore, winIntermediateCAStore}
rv []*x509.Certificate
)
// surprisingly, an empty string returns a result. We'll treat this as an error.
if subject == "" {
return nil, ErrBadCaCertMatchField
}
for _, sr := range searchLocations {
var err error
if leaf, _, err = w.certSearch(winFindSubjectStr, subject, sr, storeType); err == nil {
rv = append(rv, leaf)
} else {
// Ignore the failed search from a single location. Errors we catch include
// ErrFailedX509Extract (resulting from a malformed certificate) and errors
// around invalid attributes, unsupported algorithms, etc. These are corner
// cases as certificates with these errors shouldn't have been allowed
// to be added to the store in the first place.
if err != ErrFailedCertSearch {
return nil, err
}
}
}
// Not found anywhere
if len(rv) == 0 {
return nil, ErrFailedCertSearch
}
return rv, nil
}

// certSearch is a helper function to lookup certificates based on search type and match value.
// store is used to specify which store to perform the lookup in (system or user).
func (w *winCertStore) certSearch(searchType uint32, matchValue string, searchRoot *uint16, store uint32) (*x509.Certificate, *windows.CertContext, error) {
Expand Down
3 changes: 3 additions & 0 deletions server/certstore/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ var (
// ErrBadCertMatchField represents malformed cert_match option
ErrBadCertMatchField = errors.New("expected 'cert_match' to be a valid non-empty string")

// ErrBadCaCertMatchField represents malformed cert_match option
ErrBadCaCertMatchField = errors.New("expected 'ca_certs_match' to be a valid non-empty string array")

// ErrOSNotCompatCertStore represents cert_store passed that exists but is not valid on current OS
ErrOSNotCompatCertStore = errors.New("cert_store not compatible with current operating system")
)
53 changes: 38 additions & 15 deletions server/certstore_windows_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2022-2023 The NATS Authors
// Copyright 2022-2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
Expand Down Expand Up @@ -41,7 +41,7 @@ func runPowershellScript(scriptFile string, args []string) error {
return cmdImport.Run()
}

func runConfiguredLeaf(t *testing.T, hubPort int, certStore string, matchBy string, match string, expectedLeafCount int) {
func runConfiguredLeaf(t *testing.T, hubPort int, certStore string, matchBy string, match string, caMatch string, expectedLeafCount int) {

// Fire up the leaf
u, err := url.Parse(fmt.Sprintf("nats://localhost:%d", hubPort))
Expand All @@ -59,18 +59,18 @@ func runConfiguredLeaf(t *testing.T, hubPort int, certStore string, matchBy stri
cert_store: "%s"
cert_match_by: "%s"
cert_match: "%s"
ca_certs_match: %s
# Above should be equivalent to:
# Test settings that succeed should be equivalent to:
# cert_file: "../test/configs/certs/tlsauth/client.pem"
# key_file: "../test/configs/certs/tlsauth/client-key.pem"
ca_file: "../test/configs/certs/tlsauth/ca.pem"
# ca_file: "../test/configs/certs/tlsauth/ca.pem"
timeout: 5
}
}
]
}
`, u.String(), certStore, matchBy, match)
`, u.String(), certStore, matchBy, match, caMatch)

leafConfig := createConfFile(t, []byte(configStr))
defer removeFile(t, leafConfig)
Expand All @@ -90,7 +90,7 @@ func runConfiguredLeaf(t *testing.T, hubPort int, certStore string, matchBy stri
func TestLeafTLSWindowsCertStore(t *testing.T) {

// Client Identity (client.pem)
// Issuer: O = Synadia Communications Inc., OU = NATS.io, CN = localhost
// Issuer: O = NATS CA, OU = NATS.io, CN = localhost
// Subject: OU = NATS.io, CN = example.com

// Make sure windows cert store is reset to avoid conflict with other tests
Expand All @@ -105,6 +105,11 @@ func TestLeafTLSWindowsCertStore(t *testing.T) {
t.Fatalf("expected powershell provision to succeed: %s", err.Error())
}

err = runPowershellScript("../test/configs/certs/tlsauth/certstore/import-p12-ca.ps1", nil)
if err != nil {
t.Fatalf("expected powershell provision CA to succeed: %s", err.Error())
}

// Fire up the hub
hubConfig := createConfFile(t, []byte(`
port: -1
Expand Down Expand Up @@ -140,26 +145,38 @@ func TestLeafTLSWindowsCertStore(t *testing.T) {
certStore string
certMatchBy string
certMatch string
caCertsMatch string
expectedLeafCount int
}{
{"WindowsCurrentUser", "Subject", "example.com", 1},
{"WindowsCurrentUser", "Issuer", "Synadia Communications Inc.", 1},
{"WindowsCurrentUser", "Issuer", "Frodo Baggins, Inc.", 0},
// Test subject and issuer
{"WindowsCurrentUser", "Subject", "example.com", "\"NATS CA\"", 1},
{"WindowsCurrentUser", "Issuer", "NATS CA", "\"NATS CA\"", 1},
{"WindowsCurrentUser", "Issuer", "Frodo Baggins, Inc.", "\"NATS CA\"", 0},
// Test CAs, NATS CA is valid, others are missing
{"WindowsCurrentUser", "Subject", "example.com", "[\"NATS CA\"]", 1},
{"WindowsCurrentUser", "Subject", "example.com", "[\"GlobalSign\"]", 0},
{"WindowsCurrentUser", "Subject", "example.com", "[\"Missing NATS Cert\"]", 0},
{"WindowsCurrentUser", "Subject", "example.com", "[\"NATS CA\", \"Missing NATS Cert1\"]", 1},
{"WindowsCurrentUser", "Subject", "example.com", "[\"Missing Cert2\",\"NATS CA\"]", 1},
{"WindowsCurrentUser", "Subject", "example.com", "[\"Missing, Cert3\",\"Missing NATS Cert4\"]", 0},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s by %s match %s", tc.certStore, tc.certMatchBy, tc.certMatch), func(t *testing.T) {
testName := fmt.Sprintf("%s by %s match %s", tc.certStore, tc.certMatchBy, tc.certMatch)
t.Run(fmt.Sprintf(testName, tc.certStore, tc.certMatchBy, tc.certMatch, tc.caCertsMatch), func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
if tc.expectedLeafCount != 0 {
t.Fatalf("did not expect panic")
t.Fatalf("did not expect panic: %s", testName)
} else {
if !strings.Contains(fmt.Sprintf("%v", r), "Error processing configuration file") {
t.Fatalf("did not expect unknown panic cause")
t.Fatalf("did not expect unknown panic: %s", testName)
}
}
}
}()
runConfiguredLeaf(t, hubOptions.LeafNode.Port, tc.certStore, tc.certMatchBy, tc.certMatch, tc.expectedLeafCount)
runConfiguredLeaf(t, hubOptions.LeafNode.Port,
tc.certStore, tc.certMatchBy, tc.certMatch,
tc.caCertsMatch, tc.expectedLeafCount)
})
}
}
Expand All @@ -169,7 +186,7 @@ func TestLeafTLSWindowsCertStore(t *testing.T) {
func TestServerTLSWindowsCertStore(t *testing.T) {

// Server Identity (server.pem)
// Issuer: O = Synadia Communications Inc., OU = NATS.io, CN = localhost
// Issuer: O = NATS CA, OU = NATS.io, CN = localhost
// Subject: OU = NATS.io Operators, CN = localhost

// Make sure windows cert store is reset to avoid conflict with other tests
Expand All @@ -184,13 +201,19 @@ func TestServerTLSWindowsCertStore(t *testing.T) {
t.Fatalf("expected powershell provision to succeed: %s", err.Error())
}

err = runPowershellScript("../test/configs/certs/tlsauth/certstore/import-p12-ca.ps1", nil)
if err != nil {
t.Fatalf("expected powershell provision CA to succeed: %s", err.Error())
}

// Fire up the server
srvConfig := createConfFile(t, []byte(`
listen: "localhost:-1"
tls {
cert_store: "WindowsCurrentUser"
cert_match_by: "Subject"
cert_match: "NATS.io Operators"
ca_certs_match: ["NATS CA"]
timeout: 5
}
`))
Expand Down
25 changes: 24 additions & 1 deletion server/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ type TLSConfigOpts struct {
CertStore certstore.StoreType
CertMatchBy certstore.MatchByType
CertMatch string
CaCertsMatch []string
OCSPPeerConfig *certidp.OCSPPeerConfig
Certificates []*TLSCertPairOpt
}
Expand Down Expand Up @@ -4533,6 +4534,28 @@ func parseTLS(v interface{}, isClientCtx bool) (t *TLSConfigOpts, retErr error)
return nil, &configErr{tk, certstore.ErrBadCertMatchField.Error()}
}
tc.CertMatch = certMatch
case "ca_certs_match":
rv := []string{}
switch mv := mv.(type) {
case string:
rv = append(rv, mv)
case []string:
rv = append(rv, mv...)
case []interface{}:
for _, t := range mv {
if token, ok := t.(token); ok {
if ts, ok := token.Value().(string); ok {
rv = append(rv, ts)
continue
} else {
return nil, &configErr{tk, fmt.Sprintf("error parsing ca_cert_match: unsupported type %T where string is expected", token)}
}
} else {
return nil, &configErr{tk, fmt.Sprintf("error parsing ca_cert_match: unsupported type %T", t)}
}
}
}
tc.CaCertsMatch = rv
case "handshake_first", "first", "immediate":
switch mv := mv.(type) {
case bool:
Expand Down Expand Up @@ -4933,7 +4956,7 @@ func GenTLSConfig(tc *TLSConfigOpts) (*tls.Config, error) {
}
config.Certificates = []tls.Certificate{cert}
case tc.CertStore != certstore.STOREEMPTY:
err := certstore.TLSConfig(tc.CertStore, tc.CertMatchBy, tc.CertMatch, &config)
err := certstore.TLSConfig(tc.CertStore, tc.CertMatchBy, tc.CertMatch, tc.CaCertsMatch, &config)
if err != nil {
return nil, err
}
Expand Down
Binary file added test/configs/certs/tlsauth/certstore/ca.p12
Binary file not shown.
Binary file modified test/configs/certs/tlsauth/certstore/client.p12
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
$issuer="Synadia Communications Inc."
Get-ChildItem Cert:\CurrentUser\My | Where-Object {$_.Issuer -match $issuer} | Remove-Item
$issuer="NATS CA"
Get-ChildItem Cert:\CurrentUser\My | Where-Object {$_.Issuer -match $issuer} | Remove-Item
Get-ChildItem Cert:\CurrentUser\CA| Where-Object {$_.Issuer -match $issuer} | Remove-Item
Get-ChildItem Cert:\CurrentUser\AuthRoot | Where-Object {$_.Issuer -match $issuer} | Remove-Item
Get-ChildItem Cert:\CurrentUser\Root | Where-Object {$_.Issuer -match $issuer} | Remove-Item
7 changes: 7 additions & 0 deletions test/configs/certs/tlsauth/certstore/import-p12-ca.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
$fileLocale = $PSScriptRoot + "\ca.p12"
$Pass = ConvertTo-SecureString -String 's3cr3t' -Force -AsPlainText
$User = "whatever"
$Cred = New-Object -TypeName "System.Management.Automation.PSCredential" -ArgumentList $User, $Pass
Import-PfxCertificate -FilePath $filelocale -CertStoreLocation Cert:\CurrentUser\My -Password $Cred.Password
#Import-PfxCertificate -FilePath $filelocale -CertStoreLocation Cert:\LocalMachine\Root -Password $Cred.Password
# TODO? Move to trusted enterprise? Requires some fingerprint parsing.
4 changes: 4 additions & 0 deletions test/configs/certs/tlsauth/certstore/pkcs12.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Refresh PKCS12 files when test certificates and keys (PEM files) are refreshed (

`openssl pkcs12 -export -inkey ./client-key.pem -in ./client.pem -out client.p12`

To add the CA, use the following:

`openssl pkcs12 -export -nokeys -in ..\ca.pem -out ca.p12`

> Note: set the PKCS12 bundle password to `s3cr3t` as required by provisioning scripts
## Cert Store Provisioning Scripts
Expand Down
Binary file modified test/configs/certs/tlsauth/certstore/server.p12
Binary file not shown.

0 comments on commit f1cd3ed

Please sign in to comment.