Skip to content

Commit

Permalink
feat: adding X.509 SPIFFE auth to gRPC server
Browse files Browse the repository at this point in the history
This change adds a new gRPC server option that enables X.509 SVIDs for authentication between gRPC clients and servers.

Signed-off-by: Jonathan Gnagy <[email protected]>
  • Loading branch information
jgnagy committed Sep 26, 2023
1 parent 48f090d commit f01abb2
Show file tree
Hide file tree
Showing 11 changed files with 378 additions and 3 deletions.
91 changes: 91 additions & 0 deletions go/cmd/vttestserver/vttestserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"io"
"math/rand"
"net/url"
"os"
"os/exec"
"path"
Expand Down Expand Up @@ -307,6 +308,96 @@ func TestMtlsAuthUnauthorizedFails(t *testing.T) {
assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized")
}

func TestSPIFFEAuth(t *testing.T) {
args := os.Args
conf := config
defer resetFlags(args, conf)

// Our test root.
root := t.TempDir()

// Create the certs and configs.
tlstest.CreateCA(root)
caCert := path.Join(root, "ca-cert.pem")

tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com")
cert := path.Join(root, "vtctld-cert.pem")
key := path.Join(root, "vtctld-key.pem")

clientSPIFFEId := &url.URL{
Scheme: "spiffe",
Host: "auth.example.com",
Path: "/client",
}
tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "ClientApp", clientSPIFFEId)
clientCert := path.Join(root, "client-cert.pem")
clientKey := path.Join(root, "client-key.pem")

// When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder
// With SPIFFE authorization enabled, the certificate/SVID's SPIFFE ID must be in one of the allowed trust domains
cluster, err := startCluster(
"--grpc_auth_mode=spiffe",
fmt.Sprintf("--grpc_key=%s", key),
fmt.Sprintf("--grpc_cert=%s", cert),
fmt.Sprintf("--grpc_ca=%s", caCert),
fmt.Sprintf("--vtctld_grpc_key=%s", clientKey),
fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert),
fmt.Sprintf("--vtctld_grpc_ca=%s", caCert),
fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com"))
assert.NoError(t, err)
defer func() {
cluster.PersistentMode = false // Cleanup the tmpdir as we're done
cluster.TearDown()
}()

// startCluster will apply vschema migrations using vtctl grpc and the clientCert.
assertColumnVindex(t, cluster, columnVindex{keyspace: "test_keyspace", table: "test_table", vindex: "my_vdx", vindexType: "hash", column: "id"})
assertColumnVindex(t, cluster, columnVindex{keyspace: "app_customer", table: "customers", vindex: "hash", vindexType: "hash", column: "id"})
}

func TestSPIFFEAuthUnauthorizedFails(t *testing.T) {
args := os.Args
conf := config
defer resetFlags(args, conf)

// Our test root.
root := t.TempDir()

// Create the certs and configs.
tlstest.CreateCA(root)
caCert := path.Join(root, "ca-cert.pem")

tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com")
cert := path.Join(root, "vtctld-cert.pem")
key := path.Join(root, "vtctld-key.pem")

clientSPIFFEId := &url.URL{
Scheme: "spiffe",
Host: "some.otherdomain.com",
Path: "/anotherclient",
}
tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "AnotherApp", clientSPIFFEId)
clientCert := path.Join(root, "client-cert.pem")
clientKey := path.Join(root, "client-key.pem")

// When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder
// Force SPIFFE authorization failure by providing a client certificate with SPIFFE ID for a domain
// that is not in the list of allowed trust domains
cluster, err := startCluster(
"--grpc_auth_mode=spiffe",
fmt.Sprintf("--grpc_key=%s", key),
fmt.Sprintf("--grpc_cert=%s", cert),
fmt.Sprintf("--grpc_ca=%s", caCert),
fmt.Sprintf("--vtctld_grpc_key=%s", clientKey),
fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert),
fmt.Sprintf("--vtctld_grpc_ca=%s", caCert),
fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com"))
defer cluster.TearDown()

assert.Error(t, err)
assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized")
}

func startPersistentCluster(dir string, flags ...string) (vttest.LocalCluster, error) {
flags = append(flags, []string{
"--persistent_mode",
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/mysqlctld.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Usage of mysqlctld:
--dba_pool_size int Size of the connection pool for dba connections (default 20)
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vtctld.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Flags:
--gcs_backup_storage_root string Root prefix for all backup-related object names.
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vtgate.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Flags:
--grpc-use-static-authentication-callerid If set, will set the immediate caller id to the username authenticated by the static auth plugin.
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vttablet.txt
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ Flags:
--gh-ost-path string override default gh-ost binary full path
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vttestserver.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Usage of vttestserver:
--foreign_key_mode string This is to provide how to handle foreign key constraint in create/alter table. Valid values are: allow, disallow (default "allow")
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
4 changes: 2 additions & 2 deletions go/vt/servenv/grpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ import (
// Note servenv.GRPCServer can only be used in servenv.OnRun,
// and not before, as it is initialized right before calling OnRun.
var (
// gRPCAuth specifies which auth plugin to use. Currently only "static" and
// "mtls" are supported.
// gRPCAuth specifies which auth plugin to use. Currently only "static",
// "mtls", and "spiffe" are supported.
//
// To expose this flag, call RegisterGRPCAuthServerFlags before ParseFlags.
gRPCAuth string
Expand Down
160 changes: 160 additions & 0 deletions go/vt/servenv/grpc_server_auth_spiffe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
Copyright 2023 The Vitess 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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package servenv

import (
"context"
"crypto/x509"
"net/url"
"strings"

"github.com/spf13/pflag"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"

"vitess.io/vitess/go/vt/log"
)

var (
// spiffeTrustDomains list of allowed SPIFFE Trust Domains for client SVIDs during authorization
spiffeTrustDomains string
// SPIFFEAuthPlugin implements AuthPlugin interface
_ Authenticator = (*SPIFFEAuthPlugin)(nil)
)

// The datatype for spiffe auth Context keys
type spiffeIdKey int

const (
// Internal Context key for the authenticated SPIFFE ID
spiffeId spiffeIdKey = 0
)

func registerGRPCServerAuthSPIFFEFlags(fs *pflag.FlagSet) {
fs.StringVar(&spiffeTrustDomains, "grpc_auth_spiffe_allowed_trust_domains", spiffeTrustDomains, "List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).")
}

// SPIFFEAuthPlugin implements X.509-based SVID for SPIFFE authentication for grpc. It contains an array of trust domains
// that will be authorized to connect to the grpc server.
type SPIFFEAuthPlugin struct {
spiffeTrustDomains []string
}

// Authenticate implements Authenticator interface. This method will be used inside a middleware in grpc_server to authenticate
// incoming requests.
func (spa *SPIFFEAuthPlugin) Authenticate(ctx context.Context, fullMethod string) (context.Context, error) {
p, ok := peer.FromContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "no peer connection info")
}
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "not connected via TLS")
}

cert := tlsInfo.State.PeerCertificates[0] // Only check the leaf certificate
spiffeIdUrl, ok := validateSVIDCert(cert, spa.spiffeTrustDomains)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "client certificate not authorized")
}

log.Infof("SPIFFE auth plugin has authenticated client with SPIFFE ID %v", spiffeId)
return newSPIFFEAuthContext(ctx, spiffeIdUrl), nil
}

// Validates the given certificate as a valid SVID leaf certificate based on
// https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#4-constraints-and-usage
// and https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#5-validation
func validateSVIDCert(cert *x509.Certificate, trustedDomains []string) (*url.URL, bool) {
issuedForTrustedDomain := false

// Leaf SVIDs should have exactly one URI SAN
if len(cert.URIs) != 1 {
return nil, false
}

if cert.URIs[0].Scheme != "spiffe" {
return nil, false
}

for _, trustDomain := range trustedDomains {
if cert.URIs[0].Hostname() == trustDomain {
issuedForTrustedDomain = true
break
}
}

if !issuedForTrustedDomain {
return nil, false
}

// Leaf SVIDs should not be CA certs
if cert.IsCA {
return nil, false
}

// Leaf SVIDs should not have CA usage
if cert.KeyUsage&x509.KeyUsageCertSign != 0 {
return nil, false
}

// Leaf SVIDs should not have CRL signing usage
if cert.KeyUsage&x509.KeyUsageCRLSign != 0 {
return nil, false
}

// Leaf SVIDs must have Digital Signature usage
if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
return nil, false
}

return cert.URIs[0], true
}

func newSPIFFEAuthContext(ctx context.Context, foundSPIFFEId *url.URL) context.Context {
return context.WithValue(ctx, spiffeId, foundSPIFFEId)
}

func spiffeAuthPluginInitializer() (Authenticator, error) {
spiffeAuthPlugin := &SPIFFEAuthPlugin{
spiffeTrustDomains: strings.Split(spiffeTrustDomains, ","),
}
log.Infof("SPIFFE auth plugin has initialized successfully with allowed trust domains of %v", spiffeTrustDomains)
return spiffeAuthPlugin, nil
}

// SPIFFEIdFromContext returns the SPIFFE ID authenticated by the spiffe auth plugin and stored in the Context, if any
func SPIFFEIdFromContext(ctx context.Context) *url.URL {
spiffeId, ok := ctx.Value(spiffeId).(*url.URL)
if ok {
return spiffeId
}
return nil
}

// SPIFFETrustDomains returns the value of the
// `--grpc_auth_spiffe_allowed_trust_domains` flag.
func SPIFFETrustDomains() string {
return spiffeTrustDomains
}

func init() {
RegisterAuthPlugin("spiffe", spiffeAuthPluginInitializer)
grpcAuthServerFlagHooks = append(grpcAuthServerFlagHooks, registerGRPCServerAuthSPIFFEFlags)
}
Loading

0 comments on commit f01abb2

Please sign in to comment.