transport: add cert package, default cert source
These certs can be used for mTLS (mutual TLS) connections when Endpoint
Verification is required. A metadata file in a well-known location
signals that a client certificate should be used when opening a
connection to a Google API server.
For more information on Endpoint Validation, see
https://cloud.google.com/endpoint-verification/docs/overview
In the future, the default cert source will be used automatically by
Google API clients. This is an extension of Application Default Credentials.
Change-Id: I66033a84fd735f1ee6dfffdf4f6c0cf4649acc5e
Reviewed-on: https://code-review.googlesource.com/c/google-api-go-client/+/50370
Reviewed-by: Chris Broadfoot <cbro@google.com>
diff --git a/transport/cert/default_cert.go b/transport/cert/default_cert.go
new file mode 100644
index 0000000..c03af65
--- /dev/null
+++ b/transport/cert/default_cert.go
@@ -0,0 +1,110 @@
+// 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 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"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "sync"
+)
+
+const (
+ metadataPath = ".secureConnect"
+ metadataFile = "context_aware_metadata.json"
+)
+
+var (
+ defaultSourceOnce sync.Once
+ defaultSource Source
+ defaultSourceErr error
+)
+
+// 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
+//
+// If that file does not exist, a nil source is returned.
+func DefaultSource() (Source, error) {
+ defaultSourceOnce.Do(func() {
+ defaultSource, defaultSourceErr = newSecureConnectSource()
+ })
+ return defaultSource, defaultSourceErr
+}
+
+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) {
+ // TODO(cbro): consider caching valid certificates rather than exec'ing every time.
+ 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
+ }
+ return &cert, nil
+}
diff --git a/transport/cert/default_cert_test.go b/transport/cert/default_cert_test.go
new file mode 100644
index 0000000..0ec3c44
--- /dev/null
+++ b/transport/cert/default_cert_test.go
@@ -0,0 +1,53 @@
+// 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 (
+ "testing"
+)
+
+func TestGetClientCertificateSuccess(t *testing.T) {
+ 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("want non-nil cert, got nil")
+ }
+ if cert.PrivateKey == nil {
+ t.Error("want non-nil PrivateKey, got nil")
+ }
+}
+
+func TestGetClientCertificateFailure(t *testing.T) {
+ 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)
+ }
+}
diff --git a/transport/cert/testdata/testcert.pem b/transport/cert/testdata/testcert.pem
new file mode 100644
index 0000000..d15c396
--- /dev/null
+++ b/transport/cert/testdata/testcert.pem
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF
+MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
+ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANLJ
+hPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wok/4xIA+ui35/MmNa
+rtNuC+BdZ1tMuVCPFZcCAwEAAaNQME4wHQYDVR0OBBYEFJvKs8RfJaXTH08W+SGv
+zQyKn0H8MB8GA1UdIwQYMBaAFJvKs8RfJaXTH08W+SGvzQyKn0H8MAwGA1UdEwQF
+MAMBAf8wDQYJKoZIhvcNAQEFBQADQQBJlffJHybjDGxRMqaRmDhX0+6v02TUKZsW
+r5QuVbpQhH6u+0UgcW0jp9QwpxoPTLTWGXEWBBBurxFwiCBhkQ+V
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+MIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo
+k/4xIA+ui35/MmNartNuC+BdZ1tMuVCPFZcCAwEAAQJAEJ2N+zsR0Xn8/Q6twa4G
+6OB1M1WO+k+ztnX/1SvNeWu8D6GImtupLTYgjZcHufykj09jiHmjHx8u8ZZB/o1N
+MQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW
+SmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T
+xVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi
+D2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g==
+-----END PRIVATE KEY-----
\ No newline at end of file