From 8fec9e3f3e68b5826010b8d4aa524924143ca0e5 Mon Sep 17 00:00:00 2001 From: Daniel Modler Date: Wed, 9 Aug 2023 21:26:24 +0200 Subject: [PATCH] Certstore: Added `cert_match_skip_invalid` option Replaces #4384. Co-authored-by: Daniel Modler Co-authored-by: Neil Twigg Signed-off-by: Neil Twigg --- server/certstore/certstore_other.go | 3 +- server/certstore/certstore_windows.go | 80 +++++++++++------- server/certstore/errors.go | 3 + server/certstore_windows_test.go | 57 ++++++++++++- server/opts.go | 51 ++++++----- .../certstore/delete-cert-from-store.ps1 | 2 +- .../certs/tlsauth/certstore/expired.p12 | Bin 0 -> 2499 bytes .../tlsauth/certstore/import-p12-server.ps1 | 5 +- .../certs/tlsauth/certstore/not-expired.p12 | Bin 0 -> 2499 bytes 9 files changed, 144 insertions(+), 57 deletions(-) create mode 100644 test/configs/certs/tlsauth/certstore/expired.p12 create mode 100644 test/configs/certs/tlsauth/certstore/not-expired.p12 diff --git a/server/certstore/certstore_other.go b/server/certstore/certstore_other.go index 32cd7cc029..459b8db64a 100644 --- a/server/certstore/certstore_other.go +++ b/server/certstore/certstore_other.go @@ -26,8 +26,7 @@ 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, caCertsMatch []string, config *tls.Config) error { - _, _, _, _, _ = certStore, certMatchBy, certMatch, caCertsMatch, config +func TLSConfig(_ StoreType, _ MatchByType, _ string, _ []string, _ bool, _ *tls.Config) error { return ErrOSNotCompatCertStore } diff --git a/server/certstore/certstore_windows.go b/server/certstore/certstore_windows.go index d471afb91d..53d1d0b369 100644 --- a/server/certstore/certstore_windows.go +++ b/server/certstore/certstore_windows.go @@ -130,6 +130,7 @@ var ( winNCrypt = windows.NewLazySystemDLL("ncrypt.dll") winCertFindCertificateInStore = winCrypt32.NewProc("CertFindCertificateInStore") + winCertVerifyTimeValidity = winCrypt32.NewProc("CertVerifyTimeValidity") winCryptAcquireCertificatePrivateKey = winCrypt32.NewProc("CryptAcquireCertificatePrivateKey") winNCryptExportKey = winNCrypt.NewProc("NCryptExportKey") winNCryptOpenStorageProvider = winNCrypt.NewProc("NCryptOpenStorageProvider") @@ -171,11 +172,11 @@ type winPSSPaddingInfo struct { // 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) { +func createCACertsPool(cs *winCertStore, storeType uint32, caCertsMatch []string, skipInvalid bool) (*x509.CertPool, error) { var errs []error caPool := x509.NewCertPool() for _, s := range caCertsMatch { - lfs, err := cs.caCertsBySubjectMatch(s, storeType) + lfs, err := cs.caCertsBySubjectMatch(s, storeType, skipInvalid) if err != nil { errs = append(errs, err) } else { @@ -200,7 +201,7 @@ func createCACertsPool(cs *winCertStore, storeType uint32, caCertsMatch []string // 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 { +func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, caCertsMatch []string, skipInvalid bool, config *tls.Config) error { var ( leaf *x509.Certificate leafCtx *windows.CertContext @@ -227,11 +228,11 @@ func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, c // certByIssuer or certBySubject if certMatchBy == matchBySubject || certMatchBy == MATCHBYEMPTY { - leaf, leafCtx, err = cs.certBySubject(certMatch, scope) + leaf, leafCtx, err = cs.certBySubject(certMatch, scope, skipInvalid) } else if certMatchBy == matchByIssuer { - leaf, leafCtx, err = cs.certByIssuer(certMatch, scope) + leaf, leafCtx, err = cs.certByIssuer(certMatch, scope, skipInvalid) } else if certMatchBy == matchByThumbprint { - leaf, leafCtx, err = cs.certByThumbprint(certMatch, scope) + leaf, leafCtx, err = cs.certByThumbprint(certMatch, scope, skipInvalid) } else { return ErrBadMatchByType } @@ -251,7 +252,7 @@ func TLSConfig(certStore StoreType, certMatchBy MatchByType, certMatch string, c } // Look for CA Certificates if len(caCertsMatch) != 0 { - caPool, err := createCACertsPool(cs, scope, caCertsMatch) + caPool, err := createCACertsPool(cs, scope, caCertsMatch, skipInvalid) if err != nil { return err } @@ -339,6 +340,16 @@ func winFindCert(store windows.Handle, enc, findFlags, findType uint32, para *ui return (*windows.CertContext)(unsafe.Pointer(h)), nil } +// winVerifyCertValid wraps the CertVerifyTimeValidity and simply returns true if the certificate is valid +func winVerifyCertValid(timeToVerify *windows.Filetime, certInfo *windows.CertInfo) bool { + // this function does not document returning errors / setting lasterror + r, _, _ := winCertVerifyTimeValidity.Call( + uintptr(unsafe.Pointer(timeToVerify)), + uintptr(unsafe.Pointer(certInfo)), + ) + return r == 0 +} + // winCertStore is a store implementation for the Windows Certificate Store type winCertStore struct { Prov uintptr @@ -378,23 +389,23 @@ func winCertContextToX509(ctx *windows.CertContext) (*x509.Certificate, error) { // CertContext pointer returned allows subsequent key operations like Sign. Caller specifies // current user's personal certs or local machine's personal certs using storeType. // See CERT_FIND_ISSUER_STR description at https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfindcertificateinstore -func (w *winCertStore) certByIssuer(issuer string, storeType uint32) (*x509.Certificate, *windows.CertContext, error) { - return w.certSearch(winFindIssuerStr, issuer, winMyStore, storeType) +func (w *winCertStore) certByIssuer(issuer string, storeType uint32, skipInvalid bool) (*x509.Certificate, *windows.CertContext, error) { + return w.certSearch(winFindIssuerStr, issuer, winMyStore, storeType, skipInvalid) } // certBySubject matches and returns the first certificate found by passed subject field. // CertContext pointer returned allows subsequent key operations like Sign. 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) certBySubject(subject string, storeType uint32) (*x509.Certificate, *windows.CertContext, error) { - return w.certSearch(winFindSubjectStr, subject, winMyStore, storeType) +func (w *winCertStore) certBySubject(subject string, storeType uint32, skipInvalid bool) (*x509.Certificate, *windows.CertContext, error) { + return w.certSearch(winFindSubjectStr, subject, winMyStore, storeType, skipInvalid) } // certByThumbprint matches and returns the first certificate found by passed SHA1 thumbprint. // CertContext pointer returned allows subsequent key operations like Sign. 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) certByThumbprint(hash string, storeType uint32) (*x509.Certificate, *windows.CertContext, error) { +func (w *winCertStore) certByThumbprint(hash string, storeType uint32, skipInvalid bool) (*x509.Certificate, *windows.CertContext, error) { hb, err := hex.DecodeString(hash) if err != nil { return nil, nil, err @@ -402,7 +413,7 @@ func (w *winCertStore) certByThumbprint(hash string, storeType uint32) (*x509.Ce if len(hb) != sha1.Size { return nil, nil, fmt.Errorf("incorrect thumbprint length %d", len(hb)) } - return w.certSearch(winFindHashStr, string(hb), winMyStore, storeType) + return w.certSearch(winFindHashStr, string(hb), winMyStore, storeType, skipInvalid) } // caCertsBySubjectMatch matches and returns all matching certificates of the subject field. @@ -414,7 +425,7 @@ func (w *winCertStore) certByThumbprint(hash string, storeType uint32) (*x509.Ce // // 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) { +func (w *winCertStore) caCertsBySubjectMatch(subject string, storeType uint32, skipInvalid bool) ([]*x509.Certificate, error) { var ( leaf *x509.Certificate searchLocations = [3]*uint16{winRootStore, winAuthRootStore, winIntermediateCAStore} @@ -426,7 +437,7 @@ func (w *winCertStore) caCertsBySubjectMatch(subject string, storeType uint32) ( } for _, sr := range searchLocations { var err error - if leaf, _, err = w.certSearch(winFindSubjectStr, subject, sr, storeType); err == nil { + if leaf, _, err = w.certSearch(winFindSubjectStr, subject, sr, storeType, skipInvalid); err == nil { rv = append(rv, leaf) } else { // Ignore the failed search from a single location. Errors we catch include @@ -448,7 +459,7 @@ func (w *winCertStore) caCertsBySubjectMatch(subject string, storeType uint32) ( // 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) { +func (w *winCertStore) certSearch(searchType uint32, matchValue string, searchRoot *uint16, store uint32, skipInvalid bool) (*x509.Certificate, *windows.CertContext, error) { // store handle to "MY" store h, err := w.storeHandle(store, searchRoot) if err != nil { @@ -465,23 +476,32 @@ func (w *winCertStore) certSearch(searchType uint32, matchValue string, searchRo // pass 0 as the third parameter because it is not used // https://msdn.microsoft.com/en-us/library/windows/desktop/aa376064(v=vs.85).aspx - nc, err := winFindCert(h, winEncodingX509ASN|winEncodingPKCS7, 0, searchType, i, prev) - if err != nil { - return nil, nil, err - } - if nc != nil { - // certificate found - prev = nc - // Extract the DER-encoded certificate from the cert context - xc, err := winCertContextToX509(nc) - if err == nil { - cert = xc + for { + nc, err := winFindCert(h, winEncodingX509ASN|winEncodingPKCS7, 0, searchType, i, prev) + if err != nil { + return nil, nil, err + } + if nc != nil { + // certificate found + prev = nc + + var now *windows.Filetime + if skipInvalid && !winVerifyCertValid(now, nc.CertInfo) { + continue + } + + // Extract the DER-encoded certificate from the cert context + xc, err := winCertContextToX509(nc) + if err == nil { + cert = xc + break + } else { + return nil, nil, ErrFailedX509Extract + } } else { - return nil, nil, ErrFailedX509Extract + return nil, nil, ErrFailedCertSearch } - } else { - return nil, nil, ErrFailedCertSearch } if cert == nil { diff --git a/server/certstore/errors.go b/server/certstore/errors.go index be8b888c05..be545cf0b8 100644 --- a/server/certstore/errors.go +++ b/server/certstore/errors.go @@ -71,6 +71,9 @@ var ( // ErrBadCaCertMatchField represents malformed cert_match option ErrBadCaCertMatchField = errors.New("expected 'ca_certs_match' to be a valid non-empty string array") + // ErrBadCertMatchSkipInvalidField represents malformed cert_match_skip_invalid option + ErrBadCertMatchSkipInvalidField = errors.New("expected 'cert_match_skip_invalid' to be a boolean") + // 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") ) diff --git a/server/certstore_windows_test.go b/server/certstore_windows_test.go index 93ea441f57..d68b70ad38 100644 --- a/server/certstore_windows_test.go +++ b/server/certstore_windows_test.go @@ -28,9 +28,12 @@ import ( ) func runPowershellScript(scriptFile string, args []string) error { - _ = args psExec, _ := exec.LookPath("powershell.exe") + execArgs := []string{psExec, "-command", fmt.Sprintf("& '%s'", scriptFile)} + if len(args) > 0 { + execArgs = append(execArgs, args...) + } cmdImport := &exec.Cmd{ Path: psExec, @@ -251,3 +254,55 @@ func TestServerTLSWindowsCertStore(t *testing.T) { }) } } + +// TestServerIgnoreExpiredCerts tests if the server skips expired certificates in configuration, and finds non-expired ones +func TestServerIgnoreExpiredCerts(t *testing.T) { + + // Server Identities: expired.pem; not-expired.pem + // Issuer: OU = NATS.io, CN = localhost + // Subject: OU = NATS.io Operators, CN = localhost + + testCases := []struct { + certFile string + expect bool + }{ + {"expired.p12", false}, + {"not-expired.p12", true}, + } + for _, tc := range testCases { + t.Run(fmt.Sprintf("Server certificate: %s", tc.certFile), func(t *testing.T) { + // Make sure windows cert store is reset to avoid conflict with other tests + err := runPowershellScript("../test/configs/certs/tlsauth/certstore/delete-cert-from-store.ps1", nil) + if err != nil { + t.Fatalf("expected powershell cert delete to succeed: %s", err.Error()) + } + + // Provision Windows cert store with server cert and secret + err = runPowershellScript("../test/configs/certs/tlsauth/certstore/import-p12-server.ps1", []string{tc.certFile}) + if err != nil { + t.Fatalf("expected powershell provision 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" + cert_match_skip_invalid: true + timeout: 5 + } + `)) + defer removeFile(t, srvConfig) + cfg, _ := ProcessConfigFile(srvConfig) + if (cfg != nil) == tc.expect { + return + } + if tc.expect == false { + t.Fatalf("expected server start to fail with expired certificate") + } else { + t.Fatalf("expected server to start with non expired certificate") + } + }) + } +} diff --git a/server/opts.go b/server/opts.go index 0cc52d5b1d..c80866115b 100644 --- a/server/opts.go +++ b/server/opts.go @@ -714,27 +714,28 @@ type authorization struct { // TLSConfigOpts holds the parsed tls config information, // used with flag parsing type TLSConfigOpts struct { - CertFile string - KeyFile string - CaFile string - Verify bool - Insecure bool - Map bool - TLSCheckKnownURLs bool - HandshakeFirst bool // Indicate that the TLS handshake should occur first, before sending the INFO protocol. - FallbackDelay time.Duration // Where supported, indicates how long to wait for the handshake before falling back to sending the INFO protocol first. - Timeout float64 - RateLimit int64 - Ciphers []uint16 - CurvePreferences []tls.CurveID - PinnedCerts PinnedCertSet - CertStore certstore.StoreType - CertMatchBy certstore.MatchByType - CertMatch string - CaCertsMatch []string - OCSPPeerConfig *certidp.OCSPPeerConfig - Certificates []*TLSCertPairOpt - MinVersion uint16 + CertFile string + KeyFile string + CaFile string + Verify bool + Insecure bool + Map bool + TLSCheckKnownURLs bool + HandshakeFirst bool // Indicate that the TLS handshake should occur first, before sending the INFO protocol. + FallbackDelay time.Duration // Where supported, indicates how long to wait for the handshake before falling back to sending the INFO protocol first. + Timeout float64 + RateLimit int64 + Ciphers []uint16 + CurvePreferences []tls.CurveID + PinnedCerts PinnedCertSet + CertStore certstore.StoreType + CertMatchBy certstore.MatchByType + CertMatch string + CertMatchSkipInvalid bool + CaCertsMatch []string + OCSPPeerConfig *certidp.OCSPPeerConfig + Certificates []*TLSCertPairOpt + MinVersion uint16 } // TLSCertPairOpt are the paths to a certificate and private key. @@ -4819,6 +4820,12 @@ func parseTLS(v any, isClientCtx bool) (t *TLSConfigOpts, retErr error) { default: return nil, &configErr{tk, fmt.Sprintf("field %q should be a boolean or a string, got %T", mk, mv)} } + case "cert_match_skip_invalid": + certMatchSkipInvalid, ok := mv.(bool) + if !ok { + return nil, &configErr{tk, certstore.ErrBadCertMatchSkipInvalidField.Error()} + } + tc.CertMatchSkipInvalid = certMatchSkipInvalid case "ocsp_peer": switch vv := mv.(type) { case bool: @@ -5217,7 +5224,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, tc.CaCertsMatch, &config) + err := certstore.TLSConfig(tc.CertStore, tc.CertMatchBy, tc.CertMatch, tc.CaCertsMatch, tc.CertMatchSkipInvalid, &config) if err != nil { return nil, err } diff --git a/test/configs/certs/tlsauth/certstore/delete-cert-from-store.ps1 b/test/configs/certs/tlsauth/certstore/delete-cert-from-store.ps1 index 1cc43ddf25..3f2eccd732 100644 --- a/test/configs/certs/tlsauth/certstore/delete-cert-from-store.ps1 +++ b/test/configs/certs/tlsauth/certstore/delete-cert-from-store.ps1 @@ -2,4 +2,4 @@ $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 \ No newline at end of file +Get-ChildItem Cert:\CurrentUser\Root | Where-Object {$_.Issuer -match $issuer} | Remove-Item diff --git a/test/configs/certs/tlsauth/certstore/expired.p12 b/test/configs/certs/tlsauth/certstore/expired.p12 new file mode 100644 index 0000000000000000000000000000000000000000..5870bbc3d003ece6ed12159629f06613ca7d630c GIT binary patch literal 2499 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 Aod5s; literal 0 HcmV?d00001