feat(transport): Add Device Certificate Authentication support to GRPC (#682) - Refactor GetClientCertificateSource and GetEndpoint helpers into a common "dca" package to be used by both http and grpc routes. - Moved endpoint tests to dca_test.go. - Update GetEndpoint to accept naked "host:port" as URL without merging with DefaultEndpoint. This is needed for GRPC DirectPath scenario, where "DefaultEndpoint" is likely not configured.
diff --git a/transport/grpc/dial.go b/transport/grpc/dial.go index 19b7ddb..55c04a5 100644 --- a/transport/grpc/dial.go +++ b/transport/grpc/dial.go
@@ -9,6 +9,7 @@ import ( "context" + "crypto/tls" "errors" "log" "os" @@ -18,6 +19,7 @@ "golang.org/x/oauth2" "google.golang.org/api/internal" "google.golang.org/api/option" + "google.golang.org/api/transport/internal/dca" "google.golang.org/grpc" "google.golang.org/grpc/credentials" grpcgoogle "google.golang.org/grpc/credentials/google" @@ -112,6 +114,10 @@ if o.GRPCConn != nil { return o.GRPCConn, nil } + clientCertSource, endpoint, err := dca.GetClientCertificateSourceAndEndpoint(o) + if err != nil { + return nil, err + } var grpcOpts []grpc.DialOption if insecure { grpcOpts = []grpc.DialOption{grpc.WithInsecure()} @@ -134,9 +140,9 @@ // service account. // * Opted in via GOOGLE_CLOUD_ENABLE_DIRECT_PATH environment variable. // For example, GOOGLE_CLOUD_ENABLE_DIRECT_PATH=spanner,pubsub - if isDirectPathEnabled(o.Endpoint) && isTokenSourceDirectPathCompatible(creds.TokenSource) { - if !strings.HasPrefix(o.Endpoint, "dns:///") { - o.Endpoint = "dns:///" + o.Endpoint + if isDirectPathEnabled(endpoint) && isTokenSourceDirectPathCompatible(creds.TokenSource) { + if !strings.HasPrefix(endpoint, "dns:///") { + endpoint = "dns:///" + endpoint } grpcOpts = []grpc.DialOption{ grpc.WithCredentialsBundle( @@ -150,13 +156,16 @@ } // TODO(cbro): add support for system parameters (quota project, request reason) via chained interceptor. } else { + tlsConfig := &tls.Config{ + GetClientCertificate: clientCertSource, + } grpcOpts = []grpc.DialOption{ grpc.WithPerRPCCredentials(grpcTokenSource{ TokenSource: oauth.TokenSource{creds.TokenSource}, quotaProject: o.QuotaProject, requestReason: o.RequestReason, }), - grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), } } } @@ -180,34 +189,11 @@ // point when isDirectPathEnabled will default to true, we guard it by // the Directpath env var for now once we can introspect user defined // dialer (https://github.com/grpc/grpc-go/issues/2795). - if timeoutDialerOption != nil && isDirectPathEnabled(o.Endpoint) { + if timeoutDialerOption != nil && isDirectPathEnabled(endpoint) { grpcOpts = append(grpcOpts, timeoutDialerOption) } - return grpc.DialContext(ctx, o.Endpoint, grpcOpts...) -} - -// generateDefaultMtlsEndpoint attempts to derive the mTLS version of the -// defaultEndpoint via regex, and returns defaultEndpoint if unsuccessful. -// -// We need to applying the following 2 transformations: -// 1. pubsub.googleapis.com to pubsub.mtls.googleapis.com -// 2. pubsub.sandbox.googleapis.com to pubsub.mtls.sandbox.googleapis.com -// -// TODO(cbro): In the future, the mTLS endpoint will be read from Service Config -// and passed in as defaultMtlsEndpoint instead of generated from defaultEndpoint, -// and this function will be removed. -func generateDefaultMtlsEndpoint(defaultEndpoint string) string { - var domains = []string{ - ".sandbox.googleapis.com", // must come first because .googleapis.com is a substring - ".googleapis.com", - } - for _, domain := range domains { - if strings.Contains(defaultEndpoint, domain) { - return strings.Replace(defaultEndpoint, domain, ".mtls"+domain, -1) - } - } - return defaultEndpoint + return grpc.DialContext(ctx, endpoint, grpcOpts...) } func addOCStatsHandler(opts []grpc.DialOption, settings *internal.DialSettings) []grpc.DialOption { @@ -295,12 +281,6 @@ return nil, err } - // NOTE(cbro): this is used only by the nightly mtls_smoketest and should - // not otherwise be used. It will be removed or renamed at some point. - if os.Getenv("GOOGLE_API_USE_MTLS") == "always" { - o.Endpoint = generateDefaultMtlsEndpoint(o.Endpoint) - } - return &o, nil }
diff --git a/transport/http/dial.go b/transport/http/dial.go index a1a01c9..8578cac 100644 --- a/transport/http/dial.go +++ b/transport/http/dial.go
@@ -13,9 +13,6 @@ "errors" "net" "net/http" - "net/url" - "os" - "strings" "time" "go.opencensus.io/plugin/ochttp" @@ -25,12 +22,7 @@ "google.golang.org/api/option" "google.golang.org/api/transport/cert" "google.golang.org/api/transport/http/internal/propagation" -) - -const ( - mTLSModeAlways = "always" - mTLSModeNever = "never" - mTLSModeAuto = "auto" + "google.golang.org/api/transport/internal/dca" ) // NewClient returns an HTTP client for use communicating with a Google cloud @@ -41,11 +33,7 @@ if err != nil { return nil, "", err } - clientCertSource, err := getClientCertificateSource(settings) - if err != nil { - return nil, "", err - } - endpoint, err := getEndpoint(settings, clientCertSource) + clientCertSource, endpoint, err := dca.GetClientCertificateSourceAndEndpoint(settings) if err != nil { return nil, "", err } @@ -218,101 +206,3 @@ Propagation: &propagation.HTTPFormat{}, } } - -// getClientCertificateSource returns a default client certificate source, if -// not provided by the user. -// -// A nil default source can be returned if the source does not exist. Any exceptions -// encountered while initializing the default source will be reported as client -// error (ex. corrupt metadata file). -// -// The overall logic is as follows: -// 1. If both endpoint override and client certificate are specified, use them as is. -// 2. If user does not specify client certificate, we will attempt to use default -// client certificate. -// 3. If user does not specify endpoint override, we will use defaultMtlsEndpoint if -// client certificate is available and defaultEndpoint otherwise. -// -// Implications of the above logic: -// 1. If the user specifies a non-mTLS endpoint override but client certificate is -// available, we will pass along the cert anyway and let the server decide what to do. -// 2. If the user specifies an mTLS endpoint override but client certificate is not -// available, we will not fail-fast, but let backend throw error when connecting. -// -// We would like to avoid introducing client-side logic that parses whether the -// endpoint override is an mTLS url, since the url pattern may change at anytime. -// -// Important Note: For now, the environment variable GOOGLE_API_USE_CLIENT_CERTIFICATE -// must be set to "true" to allow certificate to be used (including user provided -// certificates). For details, see AIP-4114. -func getClientCertificateSource(settings *internal.DialSettings) (cert.Source, error) { - if !isClientCertificateEnabled() { - return nil, nil - } else if settings.HTTPClient != nil { - return nil, nil // HTTPClient is incompatible with ClientCertificateSource - } else if settings.ClientCertSource != nil { - return settings.ClientCertSource, nil - } else { - return cert.DefaultSource() - } - -} - -func isClientCertificateEnabled() bool { - useClientCert := os.Getenv("GOOGLE_API_USE_CLIENT_CERTIFICATE") - // TODO(andyrzhao): Update default to return "true" after DCA feature is fully released. - return strings.ToLower(useClientCert) == "true" -} - -// getEndpoint returns the endpoint for the service, taking into account the -// user-provided endpoint override "settings.Endpoint" -// -// If no endpoint override is specified, we will either return the default endpoint or -// the default mTLS endpoint if a client certificate is available. -// -// You can override the default endpoint (mtls vs. regular) by setting the -// GOOGLE_API_USE_MTLS_ENDPOINT environment variable. -// -// If the endpoint override is an address (host:port) rather than full base -// URL (ex. https://...), then the user-provided address will be merged into -// the default endpoint. For example, WithEndpoint("myhost:8000") and -// WithDefaultEndpoint("https://foo.com/bar/baz") will return "https://myhost:8080/bar/baz" -func getEndpoint(settings *internal.DialSettings, clientCertSource cert.Source) (string, error) { - if settings.Endpoint == "" { - mtlsMode := getMTLSMode() - if mtlsMode == mTLSModeAlways || (clientCertSource != nil && mtlsMode == mTLSModeAuto) { - return settings.DefaultMTLSEndpoint, nil - } - return settings.DefaultEndpoint, nil - } - if strings.Contains(settings.Endpoint, "://") { - // User passed in a full URL path, use it verbatim. - return settings.Endpoint, nil - } - if settings.DefaultEndpoint == "" { - return "", errors.New("WithEndpoint requires a full URL path") - } - - // Assume user-provided endpoint is host[:port], merge it with the default endpoint. - return mergeEndpoints(settings.DefaultEndpoint, settings.Endpoint) -} - -func getMTLSMode() string { - mode := os.Getenv("GOOGLE_API_USE_MTLS_ENDPOINT") - if mode == "" { - mode = os.Getenv("GOOGLE_API_USE_MTLS") // Deprecated. - } - if mode == "" { - return mTLSModeAuto - } - return strings.ToLower(mode) -} - -func mergeEndpoints(base, newHost string) (string, error) { - u, err := url.Parse(base) - if err != nil { - return "", err - } - u.Host = newHost - return u.String(), nil -}
diff --git a/transport/internal/dca/dca.go b/transport/internal/dca/dca.go new file mode 100644 index 0000000..827a945 --- /dev/null +++ b/transport/internal/dca/dca.go
@@ -0,0 +1,139 @@ +// 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 dca contains utils for implementing Device Certificate +// Authentication according to https://google.aip.dev/auth/4114 +// +// The overall logic for DCA is as follows: +// 1. If both endpoint override and client certificate are specified, use them as is. +// 2. If user does not specify client certificate, we will attempt to use default +// client certificate. +// 3. If user does not specify endpoint override, we will use defaultMtlsEndpoint if +// client certificate is available and defaultEndpoint otherwise. +// +// Implications of the above logic: +// 1. If the user specifies a non-mTLS endpoint override but client certificate is +// available, we will pass along the cert anyway and let the server decide what to do. +// 2. If the user specifies an mTLS endpoint override but client certificate is not +// available, we will not fail-fast, but let backend throw error when connecting. +// +// We would like to avoid introducing client-side logic that parses whether the +// endpoint override is an mTLS url, since the url pattern may change at anytime. +// +// This package is not intended for use by end developers. Use the +// google.golang.org/api/option package to configure API clients. +package dca + +import ( + "net/url" + "os" + "strings" + + "google.golang.org/api/internal" + "google.golang.org/api/transport/cert" +) + +const ( + mTLSModeAlways = "always" + mTLSModeNever = "never" + mTLSModeAuto = "auto" +) + +// GetClientCertificateSourceAndEndpoint is a convenience function that invokes +// getClientCertificateSource and getEndpoint sequentially and returns the client +// cert source and endpoint as a tuple. +func GetClientCertificateSourceAndEndpoint(settings *internal.DialSettings) (cert.Source, string, error) { + clientCertSource, err := getClientCertificateSource(settings) + if err != nil { + return nil, "", err + } + endpoint, err := getEndpoint(settings, clientCertSource) + if err != nil { + return nil, "", err + } + return clientCertSource, endpoint, nil +} + +// getClientCertificateSource returns a default client certificate source, if +// not provided by the user. +// +// A nil default source can be returned if the source does not exist. Any exceptions +// encountered while initializing the default source will be reported as client +// error (ex. corrupt metadata file). +// +// Important Note: For now, the environment variable GOOGLE_API_USE_CLIENT_CERTIFICATE +// must be set to "true" to allow certificate to be used (including user provided +// certificates). For details, see AIP-4114. +func getClientCertificateSource(settings *internal.DialSettings) (cert.Source, error) { + if !isClientCertificateEnabled() { + return nil, nil + } else if settings.HTTPClient != nil { + return nil, nil // HTTPClient is incompatible with ClientCertificateSource + } else if settings.ClientCertSource != nil { + return settings.ClientCertSource, nil + } else { + return cert.DefaultSource() + } +} + +func isClientCertificateEnabled() bool { + useClientCert := os.Getenv("GOOGLE_API_USE_CLIENT_CERTIFICATE") + // TODO(andyrzhao): Update default to return "true" after DCA feature is fully released. + return strings.ToLower(useClientCert) == "true" +} + +// getEndpoint returns the endpoint for the service, taking into account the +// user-provided endpoint override "settings.Endpoint". +// +// If no endpoint override is specified, we will either return the default endpoint or +// the default mTLS endpoint if a client certificate is available. +// +// You can override the default endpoint choice (mtls vs. regular) by setting the +// GOOGLE_API_USE_MTLS_ENDPOINT environment variable. +// +// If the endpoint override is an address (host:port) rather than full base +// URL (ex. https://...), then the user-provided address will be merged into +// the default endpoint. For example, WithEndpoint("myhost:8000") and +// WithDefaultEndpoint("https://foo.com/bar/baz") will return "https://myhost:8080/bar/baz" +func getEndpoint(settings *internal.DialSettings, clientCertSource cert.Source) (string, error) { + if settings.Endpoint == "" { + mtlsMode := getMTLSMode() + if mtlsMode == mTLSModeAlways || (clientCertSource != nil && mtlsMode == mTLSModeAuto) { + return settings.DefaultMTLSEndpoint, nil + } + return settings.DefaultEndpoint, nil + } + if strings.Contains(settings.Endpoint, "://") { + // User passed in a full URL path, use it verbatim. + return settings.Endpoint, nil + } + if settings.DefaultEndpoint == "" { + // If DefaultEndpoint is not configured, use the user provided endpoint verbatim. + // This allows a naked "host[:port]" URL to be used with GRPC Direct Path. + return settings.Endpoint, nil + } + + // Assume user-provided endpoint is host[:port], merge it with the default endpoint. + return mergeEndpoints(settings.DefaultEndpoint, settings.Endpoint) +} + +func getMTLSMode() string { + mode := os.Getenv("GOOGLE_API_USE_MTLS_ENDPOINT") + if mode == "" { + mode = os.Getenv("GOOGLE_API_USE_MTLS") // Deprecated. + } + if mode == "" { + return mTLSModeAuto + } + return strings.ToLower(mode) +} + +func mergeEndpoints(base, newHost string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", err + } + u.Host = newHost + return u.String(), nil +}
diff --git a/transport/http/dial_test.go b/transport/internal/dca/dca_test.go similarity index 97% rename from transport/http/dial_test.go rename to transport/internal/dca/dca_test.go index 5009ca2..2f61f08 100644 --- a/transport/http/dial_test.go +++ b/transport/internal/dca/dca_test.go
@@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package http +package dca import ( "testing" @@ -36,7 +36,7 @@ { UserEndpoint: "host:port", DefaultEndpoint: "", - WantErr: true, + Want: "host:port", }, } @@ -91,7 +91,7 @@ { UserEndpoint: "host:port", DefaultEndpoint: "", - WantErr: true, + Want: "host:port", }, }