From 1d5389b15d7a4669049cc446eba90285c67a554f Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Mon, 13 Jun 2022 11:54:53 -0700 Subject: [PATCH] feat(transport): Integrate with enterprise certificate proxy (#1570) Enterprise Certificate Proxy (https://github.com/googleapis/enterprise-certificate-proxy) is the new preferred solution for certificate based access on the client-side, since it integrates directly with the native OS keystores and does not expose the underlying private key. This new solution leverages the ECP client to replace the existing SecureConnect cert provider code-path for devices with the new ECP configuration file and helper binaries installed. (The ECP configuration file and helper binaries will eventually be distributed and managed through gCloud SDK). High-level change-list summary: - Refactored default_cert.go to split out SecureConnectSource into it's own file. - Added EnterpriseCertificateProxySource, a new client certificate source that uses the ECP client. - Updated tests and comments and added more test coverage. - Updated go.mod to include the new dependency. Note that this change is safe and backwards compatible, since the ECP configuration file will not be present on devices without ECP binaries installed, in which case the DefaultSource will fall back to using SecureConnect. Furthermore, both the new ECP code-path and the old SecureConnect code-path are gated behind the env-var GOOGLE_API_USE_CLIENT_CERTIFICATE today. --- go.mod | 1 + go.sum | 2 + transport/cert/default_cert.go | 123 +++--------------- transport/cert/default_cert_test.go | 107 --------------- transport/cert/enterprise_cert.go | 56 ++++++++ transport/cert/enterprise_cert_test.go | 45 +++++++ transport/cert/secureconnect_cert.go | 123 ++++++++++++++++++ transport/cert/secureconnect_cert_test.go | 115 ++++++++++++++++ .../cert/testdata/context_aware_metadata.json | 3 + .../context_aware_metadata_invalid_pem.json | 3 + ...ontext_aware_metadata_nonexpiring_pem.json | 3 + .../enterprise_certificate_config.json | 8 ++ ...rprise_certificate_config_invalid_pem.json | 8 ++ transport/cert/testdata/invalid.pem | 6 + ...onexpiringtestcert.pem => nonexpiring.pem} | 0 transport/cert/testdata/signer.sh | 7 + transport/cert/testdata/signer_invalid_pem.sh | 7 + transport/cert/testdata/testcert.pem | 2 +- transport/internal/ecp/test_signer.go | 101 ++++++++++++++ 19 files changed, 509 insertions(+), 211 deletions(-) delete mode 100644 transport/cert/default_cert_test.go create mode 100644 transport/cert/enterprise_cert.go create mode 100644 transport/cert/enterprise_cert_test.go create mode 100644 transport/cert/secureconnect_cert.go create mode 100644 transport/cert/secureconnect_cert_test.go create mode 100644 transport/cert/testdata/context_aware_metadata.json create mode 100644 transport/cert/testdata/context_aware_metadata_invalid_pem.json create mode 100644 transport/cert/testdata/context_aware_metadata_nonexpiring_pem.json create mode 100644 transport/cert/testdata/enterprise_certificate_config.json create mode 100644 transport/cert/testdata/enterprise_certificate_config_invalid_pem.json create mode 100644 transport/cert/testdata/invalid.pem rename transport/cert/testdata/{nonexpiringtestcert.pem => nonexpiring.pem} (100%) create mode 100755 transport/cert/testdata/signer.sh create mode 100755 transport/cert/testdata/signer_invalid_pem.sh create mode 100644 transport/internal/ecp/test_signer.go diff --git a/go.mod b/go.mod index 470bd8a9231..3d79b98b876 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( cloud.google.com/go/compute v1.6.1 github.com/google/go-cmp v0.5.8 + github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa github.com/googleapis/gax-go/v2 v2.4.0 go.opencensus.io v0.23.0 golang.org/x/net v0.0.0-20220607020251-c690dde0001d diff --git a/go.sum b/go.sum index 19fb9e52beb..351d84cf2b0 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa h1:7MYGT2XEMam7Mtzv1yDUYXANedWvwk3HKkR3MyGowy8= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= diff --git a/transport/cert/default_cert.go b/transport/cert/default_cert.go index 04aefec0afa..21d0251531c 100644 --- a/transport/cert/default_cert.go +++ b/transport/cert/default_cert.go @@ -14,32 +14,19 @@ package cert import ( "crypto/tls" - "crypto/x509" - "encoding/json" "errors" - "fmt" - "io/ioutil" - "os" - "os/exec" - "os/user" - "path/filepath" "sync" - "time" -) - -const ( - metadataPath = ".secureConnect" - metadataFile = "context_aware_metadata.json" ) // defaultCertData holds all the variables pertaining to // the default certficate source created by DefaultSource. +// +// A singleton model is used to allow the source to be reused +// by the transport layer. type defaultCertData struct { - once sync.Once - source Source - err error - cachedCertMutex sync.Mutex - cachedCert *tls.Certificate + once sync.Once + source Source + err error } var ( @@ -49,93 +36,23 @@ var ( // Source is a function that can be passed into crypto/tls.Config.GetClientCertificate. type Source func(*tls.CertificateRequestInfo) (*tls.Certificate, error) -// DefaultSource returns a certificate source that execs the command specified -// in the file at ~/.secureConnect/context_aware_metadata.json +// errSourceUnavailable is a sentinel error to indicate certificate source is unavailable. +var errSourceUnavailable = errors.New("certificate source is unavailable") + +// DefaultSource returns a certificate source using the preferred EnterpriseCertificateProxySource. +// If EnterpriseCertificateProxySource is not available, fall back to the legacy SecureConnectSource. // -// If that file does not exist, a nil source is returned. +// If neither source is available (due to missing configurations), a nil Source and a nil Error are +// returned to indicate that a default certificate source is unavailable. func DefaultSource() (Source, error) { defaultCert.once.Do(func() { - defaultCert.source, defaultCert.err = newSecureConnectSource() + defaultCert.source, defaultCert.err = NewEnterpriseCertificateProxySource("") + if errors.Is(defaultCert.err, errSourceUnavailable) { + defaultCert.source, defaultCert.err = NewSecureConnectSource("") + if errors.Is(defaultCert.err, errSourceUnavailable) { + defaultCert.source, defaultCert.err = nil, nil + } + } }) return defaultCert.source, defaultCert.err } - -type secureConnectSource struct { - metadata secureConnectMetadata -} - -type secureConnectMetadata struct { - Cmd []string `json:"cert_provider_command"` -} - -// newSecureConnectSource creates a secureConnectSource by reading the well-known file. -func newSecureConnectSource() (Source, error) { - user, err := user.Current() - if err != nil { - // Ignore. - return nil, nil - } - filename := filepath.Join(user.HomeDir, metadataPath, metadataFile) - file, err := ioutil.ReadFile(filename) - if os.IsNotExist(err) { - // Ignore. - return nil, nil - } - if err != nil { - return nil, err - } - - var metadata secureConnectMetadata - if err := json.Unmarshal(file, &metadata); err != nil { - return nil, fmt.Errorf("cert: could not parse JSON in %q: %v", filename, err) - } - if err := validateMetadata(metadata); err != nil { - return nil, fmt.Errorf("cert: invalid config in %q: %v", filename, err) - } - return (&secureConnectSource{ - metadata: metadata, - }).getClientCertificate, nil -} - -func validateMetadata(metadata secureConnectMetadata) error { - if len(metadata.Cmd) == 0 { - return errors.New("empty cert_provider_command") - } - return nil -} - -func (s *secureConnectSource) getClientCertificate(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { - defaultCert.cachedCertMutex.Lock() - defer defaultCert.cachedCertMutex.Unlock() - if defaultCert.cachedCert != nil && !isCertificateExpired(defaultCert.cachedCert) { - return defaultCert.cachedCert, nil - } - // Expand OS environment variables in the cert provider command such as "$HOME". - for i := 0; i < len(s.metadata.Cmd); i++ { - s.metadata.Cmd[i] = os.ExpandEnv(s.metadata.Cmd[i]) - } - command := s.metadata.Cmd - data, err := exec.Command(command[0], command[1:]...).Output() - if err != nil { - // TODO(cbro): read stderr for error message? Might contain sensitive info. - return nil, err - } - cert, err := tls.X509KeyPair(data, data) - if err != nil { - return nil, err - } - defaultCert.cachedCert = &cert - return &cert, nil -} - -// isCertificateExpired returns true if the given cert is expired or invalid. -func isCertificateExpired(cert *tls.Certificate) bool { - if len(cert.Certificate) == 0 { - return true - } - parsed, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return true - } - return time.Now().After(parsed.NotAfter) -} diff --git a/transport/cert/default_cert_test.go b/transport/cert/default_cert_test.go deleted file mode 100644 index 2d7e333f332..00000000000 --- a/transport/cert/default_cert_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2020 Google LLC. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cert - -import ( - "bytes" - "testing" -) - -func TestGetClientCertificateSuccess(t *testing.T) { - defaultCert.cachedCert = nil - source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/testcert.pem"}}} - cert, err := source.getClientCertificate(nil) - if err != nil { - t.Error(err) - } - if cert.Certificate == nil { - t.Error("getClientCertificate: want non-nil Certificate, got nil") - } - if cert.PrivateKey == nil { - t.Error("getClientCertificate: want non-nil PrivateKey, got nil") - } -} - -func TestGetClientCertificateFailure(t *testing.T) { - defaultCert.cachedCert = nil - source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat"}}} - _, err := source.getClientCertificate(nil) - if err == nil { - t.Error("Expecting error.") - } - if got, want := err.Error(), "tls: failed to find any PEM data in certificate input"; got != want { - t.Errorf("getClientCertificate: want %v err, got %v", want, got) - } -} - -func TestValidateMetadataSuccess(t *testing.T) { - metadata := secureConnectMetadata{Cmd: []string{"cat", "testdata/testcert.pem"}} - err := validateMetadata(metadata) - if err != nil { - t.Error(err) - } -} - -func TestValidateMetadataFailure(t *testing.T) { - metadata := secureConnectMetadata{Cmd: []string{}} - err := validateMetadata(metadata) - if err == nil { - t.Error("validateMetadata: want non-nil err, got nil") - } - if want, got := "empty cert_provider_command", err.Error(); want != got { - t.Errorf("validateMetadata: want %v err, got %v", want, got) - } -} - -func TestIsCertificateExpiredTrue(t *testing.T) { - defaultCert.cachedCert = nil - source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/testcert.pem"}}} - cert, err := source.getClientCertificate(nil) - if err != nil { - t.Error(err) - } - if !isCertificateExpired(cert) { - t.Error("isCertificateExpired: want true, got false") - } -} - -func TestIsCertificateExpiredFalse(t *testing.T) { - defaultCert.cachedCert = nil - source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/nonexpiringtestcert.pem"}}} - cert, err := source.getClientCertificate(nil) - if err != nil { - t.Error(err) - } - if isCertificateExpired(cert) { - t.Error("isCertificateExpired: want false, got true") - } -} - -func TestCertificateCaching(t *testing.T) { - defaultCert.cachedCert = nil - source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/nonexpiringtestcert.pem"}}} - cert, err := source.getClientCertificate(nil) - if err != nil { - t.Error(err) - } - if cert == nil { - t.Error("getClientCertificate: want non-nil cert, got nil") - } - if defaultCert.cachedCert == nil { - t.Error("getClientCertificate: want non-nil defaultSourceCachedCert, got nil") - } - - source = secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/testcert.pem"}}} - cert, err = source.getClientCertificate(nil) - if err != nil { - t.Error(err) - } - if !bytes.Equal(cert.Certificate[0], defaultCert.cachedCert.Certificate[0]) { - t.Error("getClientCertificate: want cached Certificate, got different Certificate") - } - if cert.PrivateKey != defaultCert.cachedCert.PrivateKey { - t.Error("getClientCertificate: want cached PrivateKey, got different PrivateKey") - } -} diff --git a/transport/cert/enterprise_cert.go b/transport/cert/enterprise_cert.go new file mode 100644 index 00000000000..eaa52e07c08 --- /dev/null +++ b/transport/cert/enterprise_cert.go @@ -0,0 +1,56 @@ +// Copyright 2022 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cert contains certificate tools for Google API clients. +// This package is intended to be used with crypto/tls.Config.GetClientCertificate. +// +// The certificates can be used to satisfy Google's Endpoint Validation. +// See https://cloud.google.com/endpoint-verification/docs/overview +// +// This package is not intended for use by end developers. Use the +// google.golang.org/api/option package to configure API clients. +package cert + +import ( + "crypto/tls" + "errors" + "os" + + "github.com/googleapis/enterprise-certificate-proxy/client" +) + +type ecpSource struct { + key *client.Key +} + +// NewEnterpriseCertificateProxySource creates a certificate source +// using the Enterprise Certificate Proxy client, which delegates +// certifcate related operations to an OS-specific "signer binary" +// that communicates with the native keystore (ex. keychain on MacOS). +// +// The configFilePath points to a config file containing relevant parameters +// such as the certificate issuer and the location of the signer binary. +// If configFilePath is empty, the client will attempt to load the config from +// a well-known gcloud location. +func NewEnterpriseCertificateProxySource(configFilePath string) (Source, error) { + key, err := client.Cred(configFilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // Config file missing means Enterprise Certificate Proxy is not supported. + return nil, errSourceUnavailable + } + return nil, err + } + + return (&ecpSource{ + key: key, + }).getClientCertificate, nil +} + +func (s *ecpSource) getClientCertificate(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { + var cert tls.Certificate + cert.PrivateKey = s.key + cert.Certificate = s.key.CertificateChain() + return &cert, nil +} diff --git a/transport/cert/enterprise_cert_test.go b/transport/cert/enterprise_cert_test.go new file mode 100644 index 00000000000..8f20887fd70 --- /dev/null +++ b/transport/cert/enterprise_cert_test.go @@ -0,0 +1,45 @@ +// Copyright 2022 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +package cert + +import ( + "errors" + "testing" +) + +func TestEnterpriseCertificateProxySource_ConfigMissing(t *testing.T) { + source, err := NewEnterpriseCertificateProxySource("missing.json") + if got, want := err, errSourceUnavailable; !errors.Is(err, errSourceUnavailable) { + t.Fatalf("NewEnterpriseCertificateProxySource: with missing config; got %v, want %v err", got, want) + } + if source != nil { + t.Errorf("NewEnterpriseCertificateProxySource: with missing config; got %v, want nil source", source) + } +} + +// This test launches a mock signer binary "test_signer.go" that uses a valid pem file. +func TestEnterpriseCertificateProxySource_GetClientCertificateSuccess(t *testing.T) { + source, err := NewEnterpriseCertificateProxySource("testdata/enterprise_certificate_config.json") + if err != nil { + t.Fatal(err) + } + cert, err := source(nil) + if err != nil { + t.Fatal(err) + } + if cert.Certificate == nil { + t.Error("getClientCertificate: got nil, want non-nil Certificate") + } + if cert.PrivateKey == nil { + t.Error("getClientCertificate: got nil, want non-nil PrivateKey") + } +} + +// This test launches a mock signer binary "test_signer.go" that uses an invalid pem file. +func TestEnterpriseCertificateProxySource_InitializationFailure(t *testing.T) { + _, err := NewEnterpriseCertificateProxySource("testdata/enterprise_certificate_config_invalid_pem.json") + if err == nil { + t.Error("NewEnterpriseCertificateProxySource: got nil, want non-nil err") + } +} diff --git a/transport/cert/secureconnect_cert.go b/transport/cert/secureconnect_cert.go new file mode 100644 index 00000000000..5913cab8017 --- /dev/null +++ b/transport/cert/secureconnect_cert.go @@ -0,0 +1,123 @@ +// Copyright 2022 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cert contains certificate tools for Google API clients. +// This package is intended to be used with crypto/tls.Config.GetClientCertificate. +// +// The certificates can be used to satisfy Google's Endpoint Validation. +// See https://cloud.google.com/endpoint-verification/docs/overview +// +// This package is not intended for use by end developers. Use the +// google.golang.org/api/option package to configure API clients. +package cert + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "os/user" + "path/filepath" + "sync" + "time" +) + +const ( + metadataPath = ".secureConnect" + metadataFile = "context_aware_metadata.json" +) + +type secureConnectSource struct { + metadata secureConnectMetadata + + // Cache the cert to avoid executing helper command repeatedly. + cachedCertMutex sync.Mutex + cachedCert *tls.Certificate +} + +type secureConnectMetadata struct { + Cmd []string `json:"cert_provider_command"` +} + +// NewSecureConnectSource creates a certificate source using +// the Secure Connect Helper and its associated metadata file. +// +// The configFilePath points to the location of the context aware metadata file. +// If configFilePath is empty, use the default context aware metadata location. +func NewSecureConnectSource(configFilePath string) (Source, error) { + if configFilePath == "" { + user, err := user.Current() + if err != nil { + // Error locating the default config means Secure Connect is not supported. + return nil, errSourceUnavailable + } + configFilePath = filepath.Join(user.HomeDir, metadataPath, metadataFile) + } + + file, err := ioutil.ReadFile(configFilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // Config file missing means Secure Connect is not supported. + return nil, errSourceUnavailable + } + return nil, err + } + + var metadata secureConnectMetadata + if err := json.Unmarshal(file, &metadata); err != nil { + return nil, fmt.Errorf("cert: could not parse JSON in %q: %w", configFilePath, err) + } + if err := validateMetadata(metadata); err != nil { + return nil, fmt.Errorf("cert: invalid config in %q: %w", configFilePath, err) + } + return (&secureConnectSource{ + metadata: metadata, + }).getClientCertificate, nil +} + +func validateMetadata(metadata secureConnectMetadata) error { + if len(metadata.Cmd) == 0 { + return errors.New("empty cert_provider_command") + } + return nil +} + +func (s *secureConnectSource) getClientCertificate(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { + s.cachedCertMutex.Lock() + defer s.cachedCertMutex.Unlock() + if s.cachedCert != nil && !isCertificateExpired(s.cachedCert) { + return s.cachedCert, nil + } + // Expand OS environment variables in the cert provider command such as "$HOME". + for i := 0; i < len(s.metadata.Cmd); i++ { + s.metadata.Cmd[i] = os.ExpandEnv(s.metadata.Cmd[i]) + } + command := s.metadata.Cmd + data, err := exec.Command(command[0], command[1:]...).Output() + if err != nil { + return nil, err + } + cert, err := tls.X509KeyPair(data, data) + if err != nil { + return nil, err + } + s.cachedCert = &cert + return &cert, nil +} + +// isCertificateExpired returns true if the given cert is expired or invalid. +func isCertificateExpired(cert *tls.Certificate) bool { + if len(cert.Certificate) == 0 { + return true + } + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return true + } + return time.Now().After(parsed.NotAfter) +} diff --git a/transport/cert/secureconnect_cert_test.go b/transport/cert/secureconnect_cert_test.go new file mode 100644 index 00000000000..961477b5b46 --- /dev/null +++ b/transport/cert/secureconnect_cert_test.go @@ -0,0 +1,115 @@ +// Copyright 2022 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +package cert + +import ( + "bytes" + "errors" + "testing" +) + +func TestSecureConnectSource_ConfigMissing(t *testing.T) { + source, err := NewSecureConnectSource("missing.json") + if got, want := err, errSourceUnavailable; !errors.Is(err, errSourceUnavailable) { + t.Fatalf("NewSecureConnectSource: with missing config; got %v, want %v err", got, want) + } + if source != nil { + t.Errorf("NewSecureConnectSource: with missing config; got %v, want nil source", source) + } +} + +func TestSecureConnectSource_GetClientCertificateSuccess(t *testing.T) { + source, err := NewSecureConnectSource("testdata/context_aware_metadata.json") + if err != nil { + t.Fatal(err) + } + cert, err := source(nil) + if err != nil { + t.Error(err) + } + if cert.Certificate == nil { + t.Error("getClientCertificate: got nil, want non-nil Certificate") + } + if cert.PrivateKey == nil { + t.Error("getClientCertificate: got nil, want non-nil PrivateKey") + } +} + +func TestSecureConnectSource_GetClientCertificateFailure(t *testing.T) { + source, err := NewSecureConnectSource("testdata/context_aware_metadata_invalid_pem.json") + if err != nil { + t.Fatal(err) + } + _, err = source(nil) + if err == nil { + t.Error("getClientCertificate: got nil, want non-nil err") + } +} + +func TestSecureConnectSource_ValidateMetadataSuccess(t *testing.T) { + metadata := secureConnectMetadata{Cmd: []string{"cat", "testdata/testcert.pem"}} + err := validateMetadata(metadata) + if err != nil { + t.Error(err) + } +} + +func TestSecureConnectSource_ValidateMetadataFailure(t *testing.T) { + metadata := secureConnectMetadata{Cmd: []string{}} + err := validateMetadata(metadata) + if err == nil { + t.Error("validateMetadata: got nil, want non-nil err") + } + if got, want := err.Error(), "empty cert_provider_command"; got != want { + t.Errorf("validateMetadata: got %v, want %v err", got, want) + } +} + +func TestSecureConnectSource_IsCertificateExpiredTrue(t *testing.T) { + source, err := NewSecureConnectSource("testdata/context_aware_metadata.json") + if err != nil { + t.Fatal(err) + } + cert, err := source(nil) + if err != nil { + t.Error(err) + } + if !isCertificateExpired(cert) { + t.Error("isCertificateExpired: got false, want true") + } +} + +func TestSecureConnectSource_IsCertificateExpiredFalse(t *testing.T) { + source, err := NewSecureConnectSource("testdata/context_aware_metadata_nonexpiring_pem.json") + if err != nil { + t.Fatal(err) + } + cert, err := source(nil) + if err != nil { + t.Error(err) + } + if isCertificateExpired(cert) { + t.Error("isCertificateExpired: got true, want false") + } +} + +func TestCertificateCaching(t *testing.T) { + source := secureConnectSource{metadata: secureConnectMetadata{Cmd: []string{"cat", "testdata/nonexpiring.pem"}}} + cert, err := source.getClientCertificate(nil) + if err != nil { + t.Fatal(err) + } + if cert == nil { + t.Fatal("getClientCertificate: got nil, want non-nil cert") + } + if source.cachedCert == nil { + t.Fatal("getClientCertificate: got nil, want non-nil cachedCert") + } + if got, want := source.cachedCert.Certificate[0], cert.Certificate[0]; !bytes.Equal(got, want) { + t.Fatalf("getClientCertificate: got %v, want %v cached Certificate", got, want) + } + if got, want := source.cachedCert.PrivateKey, cert.PrivateKey; got != want { + t.Fatalf("getClientCertificate: got %v, want %v cached PrivateKey", got, want) + } +} diff --git a/transport/cert/testdata/context_aware_metadata.json b/transport/cert/testdata/context_aware_metadata.json new file mode 100644 index 00000000000..fbfdfb14272 --- /dev/null +++ b/transport/cert/testdata/context_aware_metadata.json @@ -0,0 +1,3 @@ +{ + "cert_provider_command":["cat", "testdata/testcert.pem"] +} diff --git a/transport/cert/testdata/context_aware_metadata_invalid_pem.json b/transport/cert/testdata/context_aware_metadata_invalid_pem.json new file mode 100644 index 00000000000..d297c2135be --- /dev/null +++ b/transport/cert/testdata/context_aware_metadata_invalid_pem.json @@ -0,0 +1,3 @@ +{ + "cert_provider_command":["cat", "testdata/invalid.pem"] +} diff --git a/transport/cert/testdata/context_aware_metadata_nonexpiring_pem.json b/transport/cert/testdata/context_aware_metadata_nonexpiring_pem.json new file mode 100644 index 00000000000..5876cd34437 --- /dev/null +++ b/transport/cert/testdata/context_aware_metadata_nonexpiring_pem.json @@ -0,0 +1,3 @@ +{ + "cert_provider_command":["cat", "testdata/nonexpiring.pem"] +} diff --git a/transport/cert/testdata/enterprise_certificate_config.json b/transport/cert/testdata/enterprise_certificate_config.json new file mode 100644 index 00000000000..be9f9a3e806 --- /dev/null +++ b/transport/cert/testdata/enterprise_certificate_config.json @@ -0,0 +1,8 @@ +{ + "cert_info": { + "issuer": "Test Issuer" + }, + "libs": { + "signer_binary": "./testdata/signer.sh" + } +} diff --git a/transport/cert/testdata/enterprise_certificate_config_invalid_pem.json b/transport/cert/testdata/enterprise_certificate_config_invalid_pem.json new file mode 100644 index 00000000000..5fc2dcfc8f9 --- /dev/null +++ b/transport/cert/testdata/enterprise_certificate_config_invalid_pem.json @@ -0,0 +1,8 @@ +{ + "cert_info": { + "issuer": "Test Issuer" + }, + "libs": { + "signer_binary": "./testdata/signer_invalid_pem.sh" + } +} diff --git a/transport/cert/testdata/invalid.pem b/transport/cert/testdata/invalid.pem new file mode 100644 index 00000000000..032255fbbdc --- /dev/null +++ b/transport/cert/testdata/invalid.pem @@ -0,0 +1,6 @@ +-----BEGIN CERTIFICATE----- +MIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo +-----END PRIVATE KEY----- diff --git a/transport/cert/testdata/nonexpiringtestcert.pem b/transport/cert/testdata/nonexpiring.pem similarity index 100% rename from transport/cert/testdata/nonexpiringtestcert.pem rename to transport/cert/testdata/nonexpiring.pem diff --git a/transport/cert/testdata/signer.sh b/transport/cert/testdata/signer.sh new file mode 100755 index 00000000000..8a7b2192526 --- /dev/null +++ b/transport/cert/testdata/signer.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Copyright 2022 Google LLC. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +go run ../internal/ecp/test_signer.go testdata/testcert.pem diff --git a/transport/cert/testdata/signer_invalid_pem.sh b/transport/cert/testdata/signer_invalid_pem.sh new file mode 100755 index 00000000000..f97fb1489f9 --- /dev/null +++ b/transport/cert/testdata/signer_invalid_pem.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Copyright 2022 Google LLC. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +go run ../internal/ecp/test_signer.go testdata/invalid.pem diff --git a/transport/cert/testdata/testcert.pem b/transport/cert/testdata/testcert.pem index d15c396ba18..3f45e909126 100644 --- a/transport/cert/testdata/testcert.pem +++ b/transport/cert/testdata/testcert.pem @@ -18,4 +18,4 @@ MQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW SmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T xVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi D2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g== ------END PRIVATE KEY----- \ No newline at end of file +-----END PRIVATE KEY----- diff --git a/transport/internal/ecp/test_signer.go b/transport/internal/ecp/test_signer.go new file mode 100644 index 00000000000..38425cf677b --- /dev/null +++ b/transport/internal/ecp/test_signer.go @@ -0,0 +1,101 @@ +// Copyright 2022 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// test_signer.go is a net/rpc server that listens on stdin/stdout, exposing +// mock methods for testing enterprise certificate proxy flow. +package main + +import ( + "crypto" + "crypto/tls" + "crypto/x509" + "io" + "io/ioutil" + "log" + "net/rpc" + "os" + "time" +) + +// SignArgs encapsulate the parameters for the Sign method. +type SignArgs struct { + Digest []byte + Opts crypto.SignerOpts +} + +// EnterpriseCertSigner exports RPC methods for signing. +type EnterpriseCertSigner struct { + cert *tls.Certificate +} + +// Connection wraps a pair of unidirectional streams as an io.ReadWriteCloser. +type Connection struct { + io.ReadCloser + io.WriteCloser +} + +// Close closes c's underlying ReadCloser and WriteCloser. +func (c *Connection) Close() error { + rerr := c.ReadCloser.Close() + werr := c.WriteCloser.Close() + if rerr != nil { + return rerr + } + return werr +} + +// CertificateChain returns the credential as a raw X509 cert chain. This +// contains the public key. +func (k *EnterpriseCertSigner) CertificateChain(ignored struct{}, certificateChain *[][]byte) error { + *certificateChain = k.cert.Certificate + return nil +} + +// Public returns the first public key for this Key, in ASN.1 DER form. +func (k *EnterpriseCertSigner) Public(ignored struct{}, publicKey *[]byte) (err error) { + if len(k.cert.Certificate) == 0 { + return nil + } + cert, err := x509.ParseCertificate(k.cert.Certificate[0]) + if err != nil { + return err + } + *publicKey, err = x509.MarshalPKIXPublicKey(cert.PublicKey) + return err +} + +// Sign signs a message by encrypting a message digest. +func (k *EnterpriseCertSigner) Sign(args SignArgs, resp *[]byte) (err error) { + return nil +} + +func main() { + enterpriseCertSigner := new(EnterpriseCertSigner) + + data, err := ioutil.ReadFile(os.Args[1]) + if err != nil { + log.Fatalf("Error reading certificate: %v", err) + } + cert, _ := tls.X509KeyPair(data, data) + + enterpriseCertSigner.cert = &cert + + if err := rpc.Register(enterpriseCertSigner); err != nil { + log.Fatalf("Error registering net/rpc: %v", err) + } + + // If the parent process dies, we should exit. + // We can detect this by periodically checking if the PID of the parent + // process is 1 (https://stackoverflow.com/a/2035683). + go func() { + for { + if os.Getppid() == 1 { + log.Fatalln("Parent process died, exiting...") + } + time.Sleep(time.Second) + } + }() + + rpc.ServeConn(&Connection{os.Stdin, os.Stdout}) +}