istio.io/istio@v0.0.0-20240520182934-d79c90f27776/security/pkg/nodeagent/caclient/providers/citadel/client_test.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package citadel
    16  
    17  import (
    18  	"context"
    19  	"crypto/tls"
    20  	"fmt"
    21  	"net"
    22  	"path"
    23  	"path/filepath"
    24  	"reflect"
    25  	"strings"
    26  	"testing"
    27  	"time"
    28  
    29  	"google.golang.org/grpc"
    30  	"google.golang.org/grpc/codes"
    31  	"google.golang.org/grpc/credentials"
    32  	"google.golang.org/grpc/metadata"
    33  	"google.golang.org/grpc/status"
    34  
    35  	pb "istio.io/api/security/v1alpha1"
    36  	testutil "istio.io/istio/pilot/test/util"
    37  	"istio.io/istio/pkg/config/constants"
    38  	"istio.io/istio/pkg/file"
    39  	"istio.io/istio/pkg/monitoring/monitortest"
    40  	"istio.io/istio/pkg/security"
    41  	"istio.io/istio/pkg/spiffe"
    42  	"istio.io/istio/pkg/test/env"
    43  	"istio.io/istio/pkg/test/util/retry"
    44  	"istio.io/istio/security/pkg/credentialfetcher/plugin"
    45  	"istio.io/istio/security/pkg/monitoring"
    46  )
    47  
    48  const (
    49  	mockServerAddress = "localhost:0"
    50  )
    51  
    52  var (
    53  	fakeCert          = []string{"foo", "bar"}
    54  	fakeToken         = "Bearer fakeToken"
    55  	validToken        = "Bearer validToken"
    56  	authorizationMeta = "authorization"
    57  )
    58  
    59  type mockCAServer struct {
    60  	pb.UnimplementedIstioCertificateServiceServer
    61  	Certs         []string
    62  	Authenticator *security.FakeAuthenticator
    63  	Err           error
    64  }
    65  
    66  func (ca *mockCAServer) CreateCertificate(ctx context.Context, in *pb.IstioCertificateRequest) (*pb.IstioCertificateResponse, error) {
    67  	if ca.Authenticator != nil {
    68  		caller, err := security.Authenticate(ctx, []security.Authenticator{ca.Authenticator})
    69  		if caller == nil {
    70  			return nil, status.Error(codes.Unauthenticated, err.Error())
    71  		}
    72  	}
    73  	if ca.Err == nil {
    74  		return &pb.IstioCertificateResponse{CertChain: ca.Certs}, nil
    75  	}
    76  	return nil, ca.Err
    77  }
    78  
    79  func tlsOptions(t *testing.T) grpc.ServerOption {
    80  	t.Helper()
    81  	cert, err := tls.LoadX509KeyPair(
    82  		filepath.Join(env.IstioSrc, "./tests/testdata/certs/pilot/cert-chain.pem"),
    83  		filepath.Join(env.IstioSrc, "./tests/testdata/certs/pilot/key.pem"))
    84  	if err != nil {
    85  		t.Fatal(err)
    86  	}
    87  	peerCertVerifier := spiffe.NewPeerCertVerifier()
    88  	if err := peerCertVerifier.AddMappingFromPEM("cluster.local",
    89  		testutil.ReadFile(t, filepath.Join(env.IstioSrc, "./tests/testdata/certs/pilot/root-cert.pem"))); err != nil {
    90  		t.Fatal(err)
    91  	}
    92  	return grpc.Creds(credentials.NewTLS(&tls.Config{
    93  		Certificates: []tls.Certificate{cert},
    94  		ClientAuth:   tls.VerifyClientCertIfGiven,
    95  		ClientCAs:    peerCertVerifier.GetGeneralCertPool(),
    96  		MinVersion:   tls.VersionTLS12,
    97  	}))
    98  }
    99  
   100  func serve(t *testing.T, ca mockCAServer, opts ...grpc.ServerOption) string {
   101  	// create a local grpc server
   102  	s := grpc.NewServer(opts...)
   103  	t.Cleanup(s.Stop)
   104  	lis, err := net.Listen("tcp", mockServerAddress)
   105  	if err != nil {
   106  		t.Fatalf("failed to listen: %v", err)
   107  	}
   108  
   109  	go func() {
   110  		pb.RegisterIstioCertificateServiceServer(s, &ca)
   111  		if err := s.Serve(lis); err != nil {
   112  			t.Logf("failed to serve: %v", err)
   113  		}
   114  	}()
   115  	_, port, _ := net.SplitHostPort(lis.Addr().String())
   116  	return fmt.Sprintf("localhost:%s", port)
   117  }
   118  
   119  func TestCitadelClientRotation(t *testing.T) {
   120  	checkSign := func(t *testing.T, cli security.Client, expectError bool) {
   121  		t.Helper()
   122  		resp, err := cli.CSRSign([]byte{0o1}, 1)
   123  		if expectError != (err != nil) {
   124  			t.Fatalf("expected error:%v, got error:%v", expectError, err)
   125  		}
   126  		if !expectError && !reflect.DeepEqual(resp, fakeCert) {
   127  			t.Fatalf("expected cert: %v", resp)
   128  		}
   129  	}
   130  	certDir := filepath.Join(env.IstioSrc, "./tests/testdata/certs/pilot")
   131  	t.Run("cert always present", func(t *testing.T) {
   132  		server := mockCAServer{Certs: fakeCert, Err: nil, Authenticator: security.NewFakeAuthenticator("ca")}
   133  		addr := serve(t, server, tlsOptions(t))
   134  		opts := &security.Options{
   135  			CAEndpoint:  addr,
   136  			CredFetcher: plugin.CreateTokenPlugin("testdata/token"),
   137  			ProvCert:    certDir,
   138  		}
   139  		rootCert := path.Join(certDir, constants.RootCertFilename)
   140  		key := path.Join(certDir, constants.KeyFilename)
   141  		cert := path.Join(certDir, constants.CertChainFilename)
   142  		tlsOpts := &TLSOptions{
   143  			RootCert: rootCert,
   144  			Key:      key,
   145  			Cert:     cert,
   146  		}
   147  		cli, err := NewCitadelClient(opts, tlsOpts)
   148  		if err != nil {
   149  			t.Errorf("failed to create ca client: %v", err)
   150  		}
   151  		t.Cleanup(cli.Close)
   152  		server.Authenticator.Set("fake", "")
   153  		checkSign(t, cli, false)
   154  		// Expiring the token is harder, so just switch to only allow certs
   155  		server.Authenticator.Set("", "istiod.istio-system.svc")
   156  		checkSign(t, cli, false)
   157  		checkSign(t, cli, false)
   158  	})
   159  	t.Run("cert never present", func(t *testing.T) {
   160  		server := mockCAServer{Certs: fakeCert, Err: nil, Authenticator: security.NewFakeAuthenticator("ca")}
   161  		addr := serve(t, server, tlsOptions(t))
   162  		opts := &security.Options{
   163  			CAEndpoint:  addr,
   164  			CredFetcher: plugin.CreateTokenPlugin("testdata/token"),
   165  			ProvCert:    ".",
   166  		}
   167  		rootCert := path.Join(certDir, constants.RootCertFilename)
   168  		key := path.Join(opts.ProvCert, constants.KeyFilename)
   169  		cert := path.Join(opts.ProvCert, constants.CertChainFilename)
   170  		tlsOpts := &TLSOptions{
   171  			RootCert: rootCert,
   172  			Key:      key,
   173  			Cert:     cert,
   174  		}
   175  		cli, err := NewCitadelClient(opts, tlsOpts)
   176  		if err != nil {
   177  			t.Errorf("failed to create ca client: %v", err)
   178  		}
   179  		t.Cleanup(cli.Close)
   180  		server.Authenticator.Set("fake", "")
   181  		checkSign(t, cli, false)
   182  		server.Authenticator.Set("", "istiod.istio-system.svc")
   183  		checkSign(t, cli, true)
   184  	})
   185  	t.Run("cert present later", func(t *testing.T) {
   186  		dir := t.TempDir()
   187  		server := mockCAServer{Certs: fakeCert, Err: nil, Authenticator: security.NewFakeAuthenticator("ca")}
   188  		addr := serve(t, server, tlsOptions(t))
   189  		opts := &security.Options{
   190  			CAEndpoint:  addr,
   191  			CredFetcher: plugin.CreateTokenPlugin("testdata/token"),
   192  			ProvCert:    dir,
   193  		}
   194  		rootCert := path.Join(certDir, constants.RootCertFilename)
   195  		key := path.Join(opts.ProvCert, constants.KeyFilename)
   196  		cert := path.Join(opts.ProvCert, constants.CertChainFilename)
   197  		tlsOpts := &TLSOptions{
   198  			RootCert: rootCert,
   199  			Key:      key,
   200  			Cert:     cert,
   201  		}
   202  		cli, err := NewCitadelClient(opts, tlsOpts)
   203  		if err != nil {
   204  			t.Errorf("failed to create ca client: %v", err)
   205  		}
   206  		t.Cleanup(cli.Close)
   207  		server.Authenticator.Set("fake", "")
   208  		checkSign(t, cli, false)
   209  		checkSign(t, cli, false)
   210  		server.Authenticator.Set("", "istiod.istio-system.svc")
   211  		checkSign(t, cli, true)
   212  		if err := file.Copy(filepath.Join(certDir, "cert-chain.pem"), dir, "cert-chain.pem"); err != nil {
   213  			t.Fatal(err)
   214  		}
   215  		if err := file.Copy(filepath.Join(certDir, "key.pem"), dir, "key.pem"); err != nil {
   216  			t.Fatal(err)
   217  		}
   218  		checkSign(t, cli, false)
   219  	})
   220  }
   221  
   222  func TestCitadelClient(t *testing.T) {
   223  	testCases := map[string]struct {
   224  		server       mockCAServer
   225  		expectedCert []string
   226  		expectedErr  string
   227  		expectRetry  bool
   228  	}{
   229  		"Valid certs": {
   230  			server:       mockCAServer{Certs: fakeCert, Err: nil},
   231  			expectedCert: fakeCert,
   232  			expectedErr:  "",
   233  		},
   234  		"Error in response": {
   235  			server:       mockCAServer{Certs: nil, Err: fmt.Errorf("test failure")},
   236  			expectedCert: nil,
   237  			expectedErr:  "rpc error: code = Unknown desc = test failure",
   238  		},
   239  		"Empty response": {
   240  			server:       mockCAServer{Certs: []string{}, Err: nil},
   241  			expectedCert: nil,
   242  			expectedErr:  "invalid empty CertChain",
   243  		},
   244  		"retry": {
   245  			server:       mockCAServer{Certs: nil, Err: status.Error(codes.Unavailable, "test failure")},
   246  			expectedCert: nil,
   247  			expectedErr:  "rpc error: code = Unavailable desc = test failure",
   248  			expectRetry:  true,
   249  		},
   250  	}
   251  
   252  	for id, tc := range testCases {
   253  		t.Run(id, func(t *testing.T) {
   254  			mt := monitortest.New(t)
   255  			addr := serve(t, tc.server)
   256  			cli, err := NewCitadelClient(&security.Options{CAEndpoint: addr}, nil)
   257  			if err != nil {
   258  				t.Errorf("failed to create ca client: %v", err)
   259  			}
   260  			t.Cleanup(cli.Close)
   261  
   262  			resp, err := cli.CSRSign([]byte{0o1}, 1)
   263  			if err != nil {
   264  				if !strings.Contains(err.Error(), tc.expectedErr) {
   265  					t.Errorf("error (%s) does not match expected error (%s)", err.Error(), tc.expectedErr)
   266  				}
   267  			} else {
   268  				if tc.expectedErr != "" {
   269  					t.Errorf("expect error: %s but got no error", tc.expectedErr)
   270  				} else if !reflect.DeepEqual(resp, tc.expectedCert) {
   271  					t.Errorf("resp: got %+v, expected %v", resp, tc.expectedCert)
   272  				}
   273  			}
   274  
   275  			if tc.expectRetry {
   276  				mt.Assert("num_outgoing_retries", map[string]string{"request_type": monitoring.CSR}, monitortest.AtLeast(1))
   277  			}
   278  		})
   279  	}
   280  }
   281  
   282  type mockTokenCAServer struct {
   283  	pb.UnimplementedIstioCertificateServiceServer
   284  	Certs []string
   285  }
   286  
   287  func (ca *mockTokenCAServer) CreateCertificate(ctx context.Context, in *pb.IstioCertificateRequest) (*pb.IstioCertificateResponse, error) {
   288  	targetJWT, err := extractBearerToken(ctx)
   289  	if err != nil {
   290  		return nil, err
   291  	}
   292  	if targetJWT != validToken {
   293  		return nil, fmt.Errorf("token is not valid, wanted %q got %q", validToken, targetJWT)
   294  	}
   295  	return &pb.IstioCertificateResponse{CertChain: ca.Certs}, nil
   296  }
   297  
   298  func extractBearerToken(ctx context.Context) (string, error) {
   299  	md, ok := metadata.FromIncomingContext(ctx)
   300  	if !ok {
   301  		return "", fmt.Errorf("no metadata is attached")
   302  	}
   303  
   304  	authHeader, exists := md[authorizationMeta]
   305  	if !exists {
   306  		return "", fmt.Errorf("no HTTP authorization header exists")
   307  	}
   308  
   309  	for _, value := range authHeader {
   310  		if strings.HasPrefix(value, bearerTokenPrefix) {
   311  			return strings.TrimPrefix(value, bearerTokenPrefix), nil
   312  		}
   313  	}
   314  
   315  	return "", fmt.Errorf("no bearer token exists in HTTP authorization header")
   316  }
   317  
   318  // this test is to test whether the server side receive the correct token when
   319  // we build the CSR sign request
   320  func TestCitadelClientWithDifferentTypeToken(t *testing.T) {
   321  	testCases := map[string]struct {
   322  		server       mockTokenCAServer
   323  		expectedCert []string
   324  		expectedErr  string
   325  		token        string
   326  	}{
   327  		"Valid Token": {
   328  			server:       mockTokenCAServer{Certs: fakeCert},
   329  			expectedCert: fakeCert,
   330  			expectedErr:  "",
   331  			token:        validToken,
   332  		},
   333  		"Empty Token": {
   334  			server:       mockTokenCAServer{Certs: nil},
   335  			expectedCert: nil,
   336  			expectedErr:  "rpc error: code = Unknown desc = no HTTP authorization header exists",
   337  			token:        "",
   338  		},
   339  		"InValid Token": {
   340  			server:       mockTokenCAServer{Certs: []string{}},
   341  			expectedCert: nil,
   342  			expectedErr:  "rpc error: code = Unknown desc = token is not valid",
   343  			token:        fakeToken,
   344  		},
   345  	}
   346  
   347  	for id, tc := range testCases {
   348  		t.Run(id, func(t *testing.T) {
   349  			s := grpc.NewServer()
   350  			defer s.Stop()
   351  			lis, err := net.Listen("tcp", mockServerAddress)
   352  			if err != nil {
   353  				t.Fatalf("failed to listen: %v", err)
   354  			}
   355  			go func() {
   356  				pb.RegisterIstioCertificateServiceServer(s, &tc.server)
   357  				if err := s.Serve(lis); err != nil {
   358  					t.Logf("failed to serve: %v", err)
   359  				}
   360  			}()
   361  
   362  			opts := &security.Options{CAEndpoint: lis.Addr().String(), ClusterID: constants.DefaultClusterName, CredFetcher: plugin.CreateMockPlugin(tc.token)}
   363  			err = retry.UntilSuccess(func() error {
   364  				cli, err := NewCitadelClient(opts, nil)
   365  				if err != nil {
   366  					return fmt.Errorf("failed to create ca client: %v", err)
   367  				}
   368  				t.Cleanup(cli.Close)
   369  				resp, err := cli.CSRSign([]byte{0o1}, 1)
   370  				if err != nil {
   371  					if !strings.Contains(err.Error(), tc.expectedErr) {
   372  						return fmt.Errorf("error (%s) does not match expected error (%s)", err.Error(), tc.expectedErr)
   373  					}
   374  				} else {
   375  					if tc.expectedErr != "" {
   376  						return fmt.Errorf("expect error: %s but got no error", tc.expectedErr)
   377  					} else if !reflect.DeepEqual(resp, tc.expectedCert) {
   378  						return fmt.Errorf("resp: got %+v, expected %v", resp, tc.expectedCert)
   379  					}
   380  				}
   381  				return nil
   382  			}, retry.Timeout(2*time.Second), retry.Delay(time.Millisecond))
   383  			if err != nil {
   384  				t.Fatalf("test failed error is: %+v", err)
   385  			}
   386  		})
   387  	}
   388  }