// Copyright 2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package ptls import ( "crypto/tls" "crypto/x509" "regexp" "testing" "github.com/stretchr/testify/require" ) func TestSetAllowedCiphersForTLSOneDotTwo(t *testing.T) { t.Run("with valid ciphers, mutates the global state", func(t *testing.T) { require.Empty(t, getUserConfiguredAllowedCipherSuitesForTLSOneDotTwo()) // With no user-configured allowed ciphers, expect all the hardcoded ciphers require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, }, Default(nil).CipherSuites) require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, }, DefaultLDAP(nil).CipherSuites) require.Empty(t, Secure(nil).CipherSuites) t.Cleanup(func() { err := SetUserConfiguredAllowedCipherSuitesForTLSOneDotTwo(nil) require.NoError(t, err) require.Nil(t, getUserConfiguredAllowedCipherSuitesForTLSOneDotTwo()) require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, }, Default(nil).CipherSuites) require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, }, DefaultLDAP(nil).CipherSuites) require.Empty(t, Secure(nil).CipherSuites) }) userConfiguredAllowedCipherSuites := []string{ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // this is an LDAP-only cipher } err := SetUserConfiguredAllowedCipherSuitesForTLSOneDotTwo(userConfiguredAllowedCipherSuites) require.NoError(t, err) stored := getUserConfiguredAllowedCipherSuitesForTLSOneDotTwo() var storedNames []string for _, suite := range stored { storedNames = append(storedNames, suite.Name) } require.Equal(t, userConfiguredAllowedCipherSuites, storedNames) require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, Default(nil).CipherSuites) require.Equal(t, []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, }, DefaultLDAP(nil).CipherSuites) require.Empty(t, Secure(nil).CipherSuites) }) t.Run("SetUserConfiguredAllowedCipherSuitesForTLSOneDotTwo calls validateAllowedCiphers and returns error", func(t *testing.T) { err := SetUserConfiguredAllowedCipherSuitesForTLSOneDotTwo([]string{"foo"}) require.Regexp(t, regexp.QuoteMeta("unrecognized ciphers [foo], ciphers must be from list [TLS")+".*"+regexp.QuoteMeta("]"), err.Error()) }) } func TestConstrainCipherSuites(t *testing.T) { tests := []struct { name string cipherSuites []*tls.CipherSuite userConfiguredAllowedCipherSuites []*tls.CipherSuite wantCipherSuites []uint16 }{ { name: "with empty inputs, returns empty output", wantCipherSuites: make([]uint16, 0), }, { name: "with empty userConfiguredAllowedCipherSuites, returns cipherSuites", cipherSuites: []*tls.CipherSuite{ {ID: 0}, {ID: 1}, {ID: 2}, }, wantCipherSuites: []uint16{0, 1, 2}, }, { name: "with userConfiguredAllowedCipherSuites, returns only ciphers found in both inputs", cipherSuites: []*tls.CipherSuite{ {ID: 0}, {ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}, }, userConfiguredAllowedCipherSuites: []*tls.CipherSuite{ {ID: 1}, {ID: 3}, {ID: 999}, }, wantCipherSuites: []uint16{1, 3}, }, { name: "with all invalid userConfiguredAllowedCipherSuites, returns cipherSuites", cipherSuites: []*tls.CipherSuite{ {ID: 0}, {ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}, }, userConfiguredAllowedCipherSuites: []*tls.CipherSuite{ {ID: 1000}, {ID: 2000}, {ID: 3000}, }, wantCipherSuites: []uint16{0, 1, 2, 3, 4}, }, { name: "preserves order from cipherSuites", cipherSuites: []*tls.CipherSuite{ {ID: 0}, {ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}, }, userConfiguredAllowedCipherSuites: []*tls.CipherSuite{ {ID: 5}, {ID: 4}, {ID: 3}, {ID: 2}, }, wantCipherSuites: []uint16{2, 3, 4}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() actual := constrainCipherSuites(test.cipherSuites, test.userConfiguredAllowedCipherSuites) require.Equal(t, test.wantCipherSuites, actual) }) } } // TestTLSSecureCipherSuites checks whether golang has changed the list of secure ciphers. // This was written against golang 1.22.2. // Pinniped has chosen to use only secure ciphers returned by tls.CipherSuites, so in the future we want to be aware of // changes to this list of ciphers (additions or removals). // // If golang adds ciphers, we should consider adding them to the list of possible ciphers for Pinniped's profiles. // If golang removes ciphers, we should consider removing them from the list of possible ciphers for Pinniped's profiles. // Any cipher modifications should be added to release notes so that Pinniped admins can choose to modify their // allowedCiphers accordingly. func TestTLSSecureCipherSuites(t *testing.T) { expectedCipherSuites := []uint16{ tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, tls.TLS_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, } tlsSecureCipherSuites := tls.CipherSuites() require.Equal(t, len(expectedCipherSuites), len(tlsSecureCipherSuites)) for _, suite := range tlsSecureCipherSuites { require.False(t, suite.Insecure) require.Contains(t, expectedCipherSuites, suite.ID) } } func TestBuildTLSConfig(t *testing.T) { t.Parallel() aCertPool := x509.NewCertPool() tests := []struct { name string rootCAs *x509.CertPool cipherSuites []*tls.CipherSuite userConfiguredAllowedCipherSuiteIDs []uint16 wantConfig *tls.Config }{ { name: "happy path", rootCAs: aCertPool, cipherSuites: tls.CipherSuites(), wantConfig: &tls.Config{ MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, tls.TLS_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, //nolint:gosec // this is for testing purposes tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, }, NextProtos: []string{"h2", "http/1.1"}, RootCAs: aCertPool, }, }, { name: "with no userConfiguredAllowedCipherSuites, returns cipherSuites", cipherSuites: func() []*tls.CipherSuite { result := tls.CipherSuites() return result[:2] }(), wantConfig: &tls.Config{ MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, }, NextProtos: []string{"h2", "http/1.1"}, }, }, { name: "with allowed Ciphers, restricts CipherSuites to just those ciphers", rootCAs: aCertPool, cipherSuites: tls.CipherSuites(), userConfiguredAllowedCipherSuiteIDs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, }, wantConfig: &tls.Config{ MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, //nolint:gosec // this is a test tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, }, NextProtos: []string{"h2", "http/1.1"}, RootCAs: aCertPool, }, }, { name: "with allowed ciphers in random order, returns ciphers in the order from cipherSuites", cipherSuites: tls.CipherSuites(), userConfiguredAllowedCipherSuiteIDs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, wantConfig: &tls.Config{ MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, //nolint:gosec // this is for testing purposes tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, }, NextProtos: []string{"h2", "http/1.1"}, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() userConfiguredAllowedCipherSuites := make([]*tls.CipherSuite, 0) for _, allowedCipher := range test.userConfiguredAllowedCipherSuiteIDs { for _, cipherSuite := range tls.CipherSuites() { if allowedCipher == cipherSuite.ID { userConfiguredAllowedCipherSuites = append(userConfiguredAllowedCipherSuites, cipherSuite) } } } actualConfig := buildTLSConfig(test.rootCAs, test.cipherSuites, userConfiguredAllowedCipherSuites) require.Equal(t, test.wantConfig, actualConfig) }) } } func TestValidateAllowedCiphers(t *testing.T) { cipherSuites := tls.CipherSuites() tests := []struct { name string cipherSuites []*tls.CipherSuite userConfiguredAllowedCipherSuites []string wantCipherSuites []*tls.CipherSuite wantErr string }{ { name: "empty inputs result in empty outputs", }, { name: "with all valid inputs, returns the ciphers", cipherSuites: cipherSuites, userConfiguredAllowedCipherSuites: []string{ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", }, wantCipherSuites: func() []*tls.CipherSuite { var result []*tls.CipherSuite for _, suite := range cipherSuites { switch suite.Name { case "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": result = append(result, suite) default: } } return result }(), }, { name: "with all valid inputs, allows some legacy cipher names and returns the ciphers", cipherSuites: cipherSuites, userConfiguredAllowedCipherSuites: []string{ "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", }, wantCipherSuites: func() []*tls.CipherSuite { var result []*tls.CipherSuite for _, suite := range cipherSuites { switch suite.Name { case "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": result = append(result, suite) default: } } return result }(), }, { name: "with invalid input, return an error with all known ciphers", cipherSuites: cipherSuites[:2], userConfiguredAllowedCipherSuites: []string{"foo"}, wantErr: "unrecognized ciphers [foo], ciphers must be from list [TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384]", }, { name: "with some valid and some invalid input, return an error with all known ciphers", cipherSuites: cipherSuites[6:9], userConfiguredAllowedCipherSuites: []string{ "foo", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "bar", }, wantErr: "unrecognized ciphers [foo, bar], ciphers must be from list [TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384]", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() actual, err := validateAllowedCiphers(test.cipherSuites, test.userConfiguredAllowedCipherSuites) if len(test.wantErr) > 0 { require.ErrorContains(t, err, test.wantErr) } else { require.NoError(t, err) require.ElementsMatch(t, test.wantCipherSuites, actual) } }) } } func TestTranslateIDIntoSecureCipherSuites(t *testing.T) { tests := []struct { name string inputs []uint16 wantOutputs []uint16 }{ { name: "returns ciphers found in tls.CipherSuites", inputs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, }, wantOutputs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, }, }, { name: "returns ciphers in the input order", inputs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, }, wantOutputs: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, }, }, { name: "ignores cipher suites not returned by tls.CipherSuites", inputs: []uint16{ tls.TLS_RSA_WITH_AES_128_CBC_SHA, }, }, { name: "ignores cipher suites that only support TLS1.3", inputs: []uint16{ tls.TLS_AES_128_GCM_SHA256, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() actual := translateIDIntoSecureCipherSuites(test.inputs) require.Len(t, actual, len(test.wantOutputs)) for i, suite := range actual { require.Equal(t, test.wantOutputs[i], suite.ID) } }) } }