diff --git a/candidtest/candidtest.go b/candidtest/candidtest.go index c03996e9..f550a965 100644 --- a/candidtest/candidtest.go +++ b/candidtest/candidtest.go @@ -7,7 +7,17 @@ package candidtest import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "net/http" "net/http/httptest" + "time" "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" "github.com/juju/aclstore/v2" @@ -135,3 +145,87 @@ func AddIdentity(ctx context.Context, st store.Store, identity *store.Identity) panic(err) } } + +// GenerateTestCert generates a self-signed test certificate and returns +// the certificate, certificate PEM, and key PEM. +func GenerateTestCert(commonName string) (tls.Certificate, []byte, []byte, error) { + // Generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, nil, nil, err + } + + // Create certificate + now := time.Now() + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: now, + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + DNSNames: []string{commonName, "localhost"}, + } + + // Self-sign the certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return tls.Certificate{}, nil, nil, err + } + + // Encode certificate to PEM + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + // Encode private key to PEM + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return tls.Certificate{}, nil, nil, err + } + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + // Create tls.Certificate + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, nil, nil, err + } + + return cert, certPEM, keyPEM, nil +} + +// NewTLSServerWithConfig creates a new TLS HTTPS server with the given handler, +// TLS configuration, and certificate/key PEM data. +func NewTLSServerWithConfig(handler http.Handler, tlsConfig *tls.Config, certPEM, keyPEM []byte) *httptest.Server { + srv := httptest.NewUnstartedServer(handler) + + // Parse the certificate + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + panic(err) + } + + // Ensure the TLS config has the certificate + if tlsConfig.Certificates == nil { + tlsConfig.Certificates = []tls.Certificate{cert} + } + + // Enable HTTP/2 by setting NextProtos + if tlsConfig.NextProtos == nil { + tlsConfig.NextProtos = []string{"h2", "http/1.1"} + } + + srv.TLS = tlsConfig + srv.StartTLS() + return srv +} diff --git a/cmd/candidsrv/main.go b/cmd/candidsrv/main.go index 5fe73417..7140d764 100644 --- a/cmd/candidsrv/main.go +++ b/cmd/candidsrv/main.go @@ -114,6 +114,24 @@ func serve(conf *config.Config) error { }) } +const ( + hstsMaxAgeFormat = "max-age=%d" + hstsIncludeSubDomains = "; includeSubDomains" +) + +// hstsMiddleware adds HSTS headers when configured. +func hstsMiddleware(next http.Handler, maxAge int, includeSubDomains bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headerParams := fmt.Sprintf(hstsMaxAgeFormat, maxAge) + if includeSubDomains { + // Capital 'S and D' per RFC 6797 + headerParams += hstsIncludeSubDomains + } + w.Header().Add("Strict-Transport-Security", headerParams) + next.ServeHTTP(w, r) + }) +} + func serveIdentity(conf *config.Config, params candid.ServerParams) error { logger.Infof("setting up the identity server") params.IdentityProviders = defaultIDPs @@ -172,6 +190,11 @@ func serveIdentity(conf *config.Config, params candid.ServerParams) error { // optionally wrapped by the logging handler below. var server http.Handler = srv + // Add HSTS middleware if configured. + if conf.HSTSMaxAge > 0 { + server = hstsMiddleware(server, conf.HSTSMaxAge, conf.HSTSIncludeSubdomains) + } + if conf.AccessLog != "" { accesslog := &lumberjack.Logger{ Filename: conf.AccessLog, diff --git a/config/config.go b/config/config.go index 53dff6bd..e92daaa9 100644 --- a/config/config.go +++ b/config/config.go @@ -61,6 +61,20 @@ type Config struct { TLSCert string `yaml:"tls-cert"` TLSKey string `yaml:"tls-key"` + // HSTSMaxAge holds the max-age value for HSTS headers in seconds. + // If 0, HSTS headers will not be added. Typically set to 31536000 (1 year). + HSTSMaxAge int `yaml:"HSTS-max-age"` + + // HSTSIncludeSubdomains controls whether the includeSubDomains directive + // is added to the HSTS header. If this is true, HSTSMaxAge must be + // greater than 0. + HSTSIncludeSubdomains bool `yaml:"HSTS-include-subdomains"` + + // TLSCipherSuites holds a list of enabled TLS cipher suites. + // If empty, Go's default secure cipher suites are used. + // Values should be standard cipher suite names (e.g., "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"). + TLSCipherSuites []string `yaml:"TLS-cipher-suites"` + // PublicKey and PrivateKey holds the key pair used by the Candid // server for encryption and decryption of third party caveats. // These must be specified. @@ -139,8 +153,30 @@ type Config struct { BrandLogoLocation string `yaml:"brand-logo-location"` } -// TLSConfig returns a TLS configuration to be used for serving -// the API. If the TLS certficate and key are not specified, it returns nil. +func parseCipherSuites(names []string) ([]uint16, error) { + // this list is inspired by Golang's current cypher suite prioritization: + // https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/crypto/tls/cipher_suites.go + var suites []uint16 + + for _, name := range names { + var cipherSuiteSupported = false + for _, cs := range tls.CipherSuites() { + if cs.Name == name { + suites = append(suites, cs.ID) + cipherSuiteSupported = true + } + } + if !cipherSuiteSupported { + return nil, errgo.Newf("Unsupported cipher suite: %s", name) + } + } + return suites, nil +} + +// TLSConfig returns a TLS configuration to be used for serving the API. +// If the TLS certificate and key are not specified, it returns nil. +// If tls-cipher-suites are configured they will be used. +// If tls-cipher-suites are not configured the Golang defaults are used. func (c *Config) TLSConfig() *tls.Config { if c.TLSCert == "" || c.TLSKey == "" { return nil @@ -151,12 +187,21 @@ func (c *Config) TLSConfig() *tls.Config { logger.Errorf("cannot create certificate: %s", err) return nil } - return &tls.Config{ - Certificates: []tls.Certificate{ - cert, - }, - MinVersion: tls.VersionTLS12, + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, } + + if len(c.TLSCipherSuites) > 0 { + cipherSuites, err := parseCipherSuites(c.TLSCipherSuites) + if err != nil { + logger.Errorf("cannot parse cipher suites: %s", err) + return nil + } + tlsConfig.CipherSuites = cipherSuites + } + return tlsConfig } func (c *Config) validate() error { @@ -168,6 +213,9 @@ func (c *Config) validate() error { if c.ListenAddress == "" { missing = append(missing, "listen-address") } + if c.HSTSIncludeSubdomains && c.HSTSMaxAge == 0 { + missing = append(missing, "HSTS-max-age (required when HSTS-include-subdomains is true)") + } if c.PrivateKey == nil { missing = append(missing, "private-key") } diff --git a/config/config_test.go b/config/config_test.go index 4f8d3411..7b8fe654 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -85,6 +85,99 @@ tls-key: | ORfedwfVln37uivduCeyBLMhaYWiW6CN4Di/d8LsI1hwe1MlNHuV2EptaFDzfjx8 FWQQKAkL5KolhJye0Kz/X8CT3UMmhOK73UkUaOvMvdSjxLFgIruxWQ== -----END RSA PRIVATE KEY----- +HSTS-max-age: 31536000 +HSTS-include-subdomains: true +TLS-cipher-suites: +- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 +- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +resource-path: /resources +http-proxy: http://proxy.example.com:3128 +no-proxy: localhost,.example.com +redirect-login-trusted-urls: +- https://example.com/1 +- https://example.com/2 +redirect-login-trusted-domains: +- www.example.com +- "*.example.net" +api-macaroon-timeout: 2h +discharge-macaroon-timeout: 24h +discharge-token-timeout: 6h +enable-email-login: true +` + +const testConfigWithUnsupportedCipher = ` +listen-address: 1.2.3.4:5678 +foo: 1 +bar: false +admin-password: mypasswd +private-key: 8PjzjakvIlh3BVFKe8axinRDutF6EDIfjtuf4+JaNow= +public-key: CIdWcEUN+0OZnKW9KwruRQnQDY/qqzVdD30CijwiWCk= +admin-agent-public-key: dUnC8p9p3nygtE2h92a47Ooq0rXg0fVSm3YBWou5/UQ= +location: http://foo.com:1234 +storage: + type: test + attribute: hello +rendezvous-timeout: 1m +identity-providers: + - type: usso + - type: keystone + name: ks1 + url: http://example.com/keystone +private-addr: localhost +tls-cert: | + -----BEGIN CERTIFICATE----- + MIIDLDCCAhQCCQDVXrWn1thP6DANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQGEwJH + QjENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN + MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0xNjA3MDcxMjE2MDBaFw0z + NjA3MDIxMjE2MDBaMFgxCzAJBgNVBAYTAkdCMQ0wCwYDVQQIDARUZXN0MQ0wCwYD + VQQHDARUZXN0MQ0wCwYDVQQKDARUZXN0MQ0wCwYDVQQLDARUZXN0MQ0wCwYDVQQD + DARUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3uRyzTaYMWj/ + aGjqQtCMf4VMLIcR4o+yJVUp7CvhHIa/Ykx32OZMLth6DihykYzOFZj9wzD2a+GB + 8P3RkDMP5dxQF9yQSTTl/Ec7ZkHHnJzpao9mGsfJ7h24F4XTKC7QovaNw5HV83ej + Vwrose8BHe5UlEpncTIqOY3JJbzzkrzSMzS7cGB1l55zXpDQVcRzv/182qFX2L3+ + ukIlbt3PNAjGPgKWYeVameTL38oKjJ5ftrADWjAWc7IBPw65KvqOTj5Jw+Jhkj4H + 4kkXKKn8N6ItiWclpWuKi8Va36VVUXnqPxOWnIK4AGnO8WEArRhU7XK+EiFK8TuH + SSrOh9myWQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBvuGuwGrMSHNOKrWrWnwKD + T3Ge9FfUonBmkzvGmWHfLROju3mwxAP0lB10+sn1gnjUHzKiVjeY8fuAjFQMrKUp + HUWCaVPjsExd2OYu+6f+06rTrP98BNopuYWeIIkmc3JoFwOmSKTA5JIlNBDsN5F/ + PFcXE9Xjc4Ob1ut/bv6hJ1nbgbaVSNB4Zc+3oxi2X+xBut8zqATq7JYvO0SVH6h1 + oSp3lveosF9AQB8uLtWZFf3wnburr2zG6UhkSwQdy0GYEwTtqaYs7Ue7bHvO0GYG + zPCVixoo4QoTiwDV7HGodrjvcMgtUgoDOhR6daZPEYV6rQJJoGhMF5+UAgS2KiMh + -----END CERTIFICATE----- +tls-key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA3uRyzTaYMWj/aGjqQtCMf4VMLIcR4o+yJVUp7CvhHIa/Ykx3 + 2OZMLth6DihykYzOFZj9wzD2a+GB8P3RkDMP5dxQF9yQSTTl/Ec7ZkHHnJzpao9m + GsfJ7h24F4XTKC7QovaNw5HV83ejVwrose8BHe5UlEpncTIqOY3JJbzzkrzSMzS7 + cGB1l55zXpDQVcRzv/182qFX2L3+ukIlbt3PNAjGPgKWYeVameTL38oKjJ5ftrAD + WjAWc7IBPw65KvqOTj5Jw+Jhkj4H4kkXKKn8N6ItiWclpWuKi8Va36VVUXnqPxOW + nIK4AGnO8WEArRhU7XK+EiFK8TuHSSrOh9myWQIDAQABAoIBAGP7qhuvv7l6Vgep + +FucXUneq3rV5AnzV4AzoaiVTleTgko/7wrW05m39ZhgQHRV6yP5CuwCDKf78mP+ + F4FNxnXfy/XINNkB56Cw+041d6sjH/ly9eRRdp1fq3KxzzSZO3G+k30E8CpUomqr + NBKNGb0pabtTXO+EBzjmBzLsfX52anGEi2U2I/Q2srU+3FAkhjb9s9ZSgWh9zgrS + 0sK/oO04dlTLV0weq2oTHCX/ygQZpXvRXNJJVDRtst3R9EfUKr4YLWEK2k1PgWC+ + 52CJoYETbQPGiJbzReTgYTlZYHSZfuso20sPfOc01qgcJIk5qOAS2dgU5EanSQEM + 0/HJ02ECgYEA+lHafm4psqi6YWLV0Evr54kzUVYXaBY/8Qbf4psCZ0o9VjfwzIPG + ncgGXhyv9qlnFx38YEKAvn/HV52J8Qi5I8k4TBtYB/GYcNvpcNgR6uMcg+nS+0nf + Y0BJgyUwY7Exh2BTIkJKLzIoOK0RKe1pk99Iboee9MDv6YaHQqlXaGUCgYEA4/ND + 3jb0PTEDrCtDTYOhNqcW/ER6rq8vSwR7uiHGBY6OiYcFgmV/AC3SUpVQurw5YIxh + kQ1s7ncdBNN6fOpUEFYmBhPAkoHbVIcg98ZnzqM3tQU9o4sujT9pd8ATthAlqaBR + G+5s7Cil9RtggCBXL1G+CQPS2TJoE8Tr/SfnEOUCgYA4Dx7Ek71I4pqi9rR1qpsR + Rlu0yngBeoIlY2m+YQKfyTOFXI/T7WsMqOAsMXaC4htRRQjhMeONRiaJi6F51n9H + 8WdnO/RyCvwdwlI8UFdq6CPZswLp/fhGTP5pnWmB2gwCimLz2C6u9Sem0bN3VVEA + qc+Z2UuS+qaAAP3Hww7tNQKBgQDO6gXEEzwWw4Qi5057cS2Ib5m0ufBm2oxiWxp4 + danLZ4DJI7ADkl/66J0O64zRRIQMuMDjqz0jJSpJNDHua8KM5bY0M//MvWU7UEHD + x+x4rL2naq9t4awK+PGiis8Zp4SYefbGFOH4aFlkqUoqY7DgOiH3Cup8z32b3Fee + f3cGZQKBgQDvsz2cBGNFW+U03sDeHqBbdim6E2RRvPrxLkeljSiU9RzJ3P76Ousv + ORfedwfVln37uivduCeyBLMhaYWiW6CN4Di/d8LsI1hwe1MlNHuV2EptaFDzfjx8 + FWQQKAkL5KolhJye0Kz/X8CT3UMmhOK73UkUaOvMvdSjxLFgIruxWQ== + -----END RSA PRIVATE KEY----- +HSTS-max-age: 31536000 +HSTS-include-subdomains: true +TLS-cipher-suites: +- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 +- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +- WRONG_CIPHER_SUITE resource-path: /resources http-proxy: http://proxy.example.com:3128 no-proxy: localhost,.example.com @@ -117,10 +210,17 @@ func TestRead(t *testing.T) { idp.Register("usso", testIdentityProvider) idp.Register("keystone", testIdentityProvider) store.Register("test", testStorageBackend) + + // check if wrong cipher suite names are detected + conf, _ := readConfig(c, testConfigWithUnsupportedCipher) + tlsConfig := conf.TLSConfig() + c.Assert(tlsConfig, qt.IsNil) + + // continue with valid config conf, err := readConfig(c, testConfig) c.Assert(err, qt.IsNil) // Check that the TLS configuration creates a valid *tls.Config - tlsConfig := conf.TLSConfig() + tlsConfig = conf.TLSConfig() c.Assert(tlsConfig, qt.Not(qt.IsNil)) conf.TLSCert = "" conf.TLSKey = "" @@ -159,17 +259,23 @@ func TestRead(t *testing.T) { }, }, }}, - ListenAddress: "1.2.3.4:5678", - AdminPassword: "mypasswd", - PrivateKey: &key.Private, - PublicKey: &key.Public, - AdminAgentPublicKey: &adminPubKey, - Location: "http://foo.com:1234", - RendezvousTimeout: config.DurationString{Duration: time.Minute}, - PrivateAddr: "localhost", - ResourcePath: "/resources", - HTTPProxy: "http://proxy.example.com:3128", - NoProxy: "localhost,.example.com", + ListenAddress: "1.2.3.4:5678", + AdminPassword: "mypasswd", + PrivateKey: &key.Private, + PublicKey: &key.Public, + AdminAgentPublicKey: &adminPubKey, + Location: "http://foo.com:1234", + RendezvousTimeout: config.DurationString{Duration: time.Minute}, + HSTSMaxAge: 31536000, + HSTSIncludeSubdomains: true, + TLSCipherSuites: []string{ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + }, + PrivateAddr: "localhost", + ResourcePath: "/resources", + HTTPProxy: "http://proxy.example.com:3128", + NoProxy: "localhost,.example.com", RedirectLoginTrustedURLs: []string{ "https://example.com/1", "https://example.com/2", diff --git a/docs/configuration.md b/docs/configuration.md index 4a767df2..aa78c422 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,6 +20,11 @@ storage: address: localhost:27017 public-key: OAG9EVDFgXzWQKIk+MTxpLVO1Mp1Ws/pIkzhxv5Jk1M= private-key: q2G3A2NjTe7MP9D8iugCH9XfBAyrnV8n8u8ACbNyNOY= +HSTS-max-age: 31536000 +HSTS-include-subdomains: true +TLS-cipher-suites: + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 identity-providers: - type: usso ``` @@ -62,6 +67,34 @@ The access-log configures the name of a file used to record all accesses to the identity manager. If this is not configured then no logging will take place. +### HSTS-max-age +This configures the max-age value (in seconds) for HTTP Strict Transport Security (HSTS) headers. When set to a positive value, Candid will add the Strict-Transport-Security header to all responses, instructing browsers to only access the service over HTTPS for the specified duration. + +A typical value is 31536000 (one year). If set to 0 or not configured, HSTS headers will not be added. + +HSTS is a security feature that helps protect against protocol downgrade attacks and cookie hijacking by ensuring that browsers only connect to the server via HTTPS. + +### HSTS-include-subdomains +When set to `true`, adds the `includeSubDomains` directive to the HSTS header, which applies the HSTS policy to all subdomains of the Candid service. This setting requires `HSTS-max-age` to be greater than 0. + +### TLS-cipher-suites +This configures the list of enabled TLS cipher suites for TLS 1.2 connections. If not specified, Go's default secure cipher suites will be used. + +Values should be standard cipher suite names as defined in the Go `crypto/tls` package. For example: + +```yaml +TLS-cipher-suites: + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 +``` + +Important Notes: + +- This setting only applies to TLS 1.2 connections. TLS 1.3 cipher suites are not configurable in Go and will always use the secure defaults (`TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`). +- Invalid cipher suite names will cause Candid to fail to start. + ### identity-providers This is a list of the configured identity providers with their diff --git a/server_cipher_test.go b/server_cipher_test.go new file mode 100644 index 00000000..74dade2c --- /dev/null +++ b/server_cipher_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +// Note: for TLS 1.3 golang does not allow configuring cipher suites, +// so these tests focus on TLS 1.2. + +package candid_test + +import ( + "crypto/tls" + "net/http" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" + "github.com/juju/aclstore/v2" + "github.com/juju/simplekv/memsimplekv" + + "github.com/canonical/candid" + "github.com/canonical/candid/candidtest" + "github.com/canonical/candid/store/memstore" +) + +func serverTLSCipherSuitesRunner(t *testing.T, configuredCiphers []string, clientTLSConfig *tls.Config) { + c := qt.New(t) + + // Generate a test certificate, key, and self-sign + key, err := bakery.GenerateKey() + c.Assert(err, qt.IsNil) + cert, certPEM, keyPEM, err := candidtest.GenerateTestCert("localhost") + c.Assert(err, qt.IsNil) + + params := candid.ServerParams{ + Store: memstore.NewStore(), + MeetingStore: memstore.NewMeetingStore(), + ProviderDataStore: memstore.NewProviderDataStore(), + RootKeyStore: bakery.NewMemRootKeyStore(), + ACLStore: aclstore.NewACLStore(memsimplekv.NewStore()), + Key: key, + Location: "https://localhost", + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, // Force TLS 1.2 to test cipher suite configuration + NextProtos: []string{"h2", "http/1.1"}, + } + + var cipherSuiteIDs []uint16 + for _, cipherName := range configuredCiphers { + for _, cs := range tls.CipherSuites() { + if cs.Name == cipherName { + cipherSuiteIDs = append(cipherSuiteIDs, cs.ID) + break + } + } + } + tlsConfig.CipherSuites = cipherSuiteIDs + + // HTTPS server + handler, err := candid.NewServer(params, candid.V1) + c.Assert(err, qt.IsNil) + defer handler.Close() + srv := candidtest.NewTLSServerWithConfig(handler, tlsConfig, certPEM, keyPEM) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: clientTLSConfig, + ForceAttemptHTTP2: true, + }, + } + + resp, err := client.Get(srv.URL + "/v1/discharge") + c.Assert(err, qt.IsNil) + defer func() { c.Assert(resp.Body.Close(), qt.IsNil) }() + + c.Assert(resp.TLS, qt.Not(qt.IsNil), qt.Commentf("expected TLS connection")) + + c.Assert(resp.Proto, qt.Equals, "HTTP/2.0") + + // Verify that the cipher suite used is one of the configured ones + usedCipherID := resp.TLS.CipherSuite + found := false + for _, id := range cipherSuiteIDs { + if id == usedCipherID { + found = true + break + } + } + c.Assert(found, qt.IsTrue, qt.Commentf( + "cipher suite %d not in configured suites: %v", + usedCipherID, cipherSuiteIDs, + )) +} + +func TestServerTLSCipherSuites(t *testing.T) { + configuredCiphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + } + + clientTLSConfig := &tls.Config{ + InsecureSkipVerify: true, + } + + serverTLSCipherSuitesRunner(t, configuredCiphers, clientTLSConfig) +} + +func TestServerTLSCipherSuitesRestriction(t *testing.T) { + // Define only one cipher suite to be available + configuredCiphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + } + + // Create a client that prefers a different cipher (but still compatible) + // This tests that the server enforces its cipher preferences + clientTLSConfig := &tls.Config{ + InsecureSkipVerify: true, + CipherSuites: []uint16{ + // prefer unconfigured cipher suites + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + }, + PreferServerCipherSuites: false, + } + + serverTLSCipherSuitesRunner(t, configuredCiphers, clientTLSConfig) +}