From 0ffb9aba5511f198f8879cb59bdf775a932e8c3a Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 7 Jan 2026 13:44:40 +0100 Subject: [PATCH 01/10] feat (WIP): add hsts middleware and configurable ciphers - adds HSTS middleware - makes cipher suites configurable - TODO: instead of defining ciphers iterate over what go has --- cmd/candidsrv/main.go | 20 ++++++++++++++++ config/config.go | 55 +++++++++++++++++++++++++++++++++++++------ config/config_test.go | 6 +++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/cmd/candidsrv/main.go b/cmd/candidsrv/main.go index 5fe73417..6abc4e4b 100644 --- a/cmd/candidsrv/main.go +++ b/cmd/candidsrv/main.go @@ -114,6 +114,21 @@ func serve(conf *config.Config) error { }) } +// 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) { + if maxAge > 0 { + headerParams := fmt.Sprintf("max-age: %d", maxAge) + if includeSubDomains { + // Capital 'D' per RFC 6797 + headerParams += "; includeSubDomains" + } + 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 +187,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..7e30a6e4 100644 --- a/config/config.go +++ b/config/config.go @@ -61,6 +61,19 @@ 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. + 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 +152,27 @@ 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 golangs 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 _, cs := range tls.CipherSuites() { + for _, name := range names { + if cs.Name == name { + suites = append(suites, cs.ID) + } else { + return nil, errgo.Newf("Unknown cipher suite name: %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 +183,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 { diff --git a/config/config_test.go b/config/config_test.go index 4f8d3411..ccb86998 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -85,6 +85,12 @@ 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 +- WRONG_CIPHER_SUITE resource-path: /resources http-proxy: http://proxy.example.com:3128 no-proxy: localhost,.example.com From 574008edd61a4fb725dd08b04c983e9aa4b254ed Mon Sep 17 00:00:00 2001 From: Thorsten Date: Thu, 15 Jan 2026 18:36:22 +0100 Subject: [PATCH 02/10] fix: parsing of cipher suites Make uppercase of HSTS and TLS consistent in YAML and config. Also add test with incorrect config to validate that wrong cipher names are detected by config parser. --- cmd/candidsrv/main.go | 2 +- config/config.go | 20 ++++--- config/config_test.go | 126 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 21 deletions(-) diff --git a/cmd/candidsrv/main.go b/cmd/candidsrv/main.go index 6abc4e4b..f7c8ccf5 100644 --- a/cmd/candidsrv/main.go +++ b/cmd/candidsrv/main.go @@ -120,7 +120,7 @@ func hstsMiddleware(next http.Handler, maxAge int, includeSubDomains bool) http. if maxAge > 0 { headerParams := fmt.Sprintf("max-age: %d", maxAge) if includeSubDomains { - // Capital 'D' per RFC 6797 + // Capital 'S and D' per RFC 6797 headerParams += "; includeSubDomains" } w.Header().Add("Strict-Transport-Security", headerParams) diff --git a/config/config.go b/config/config.go index 7e30a6e4..120f5ba9 100644 --- a/config/config.go +++ b/config/config.go @@ -63,16 +63,16 @@ type Config struct { // 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"` + HSTSMaxAge int `yaml:"HSTS-max-age"` // HSTSIncludeSubdomains controls whether the includeSubDomains directive // is added to the HSTS header. - HSTSIncludeSubdomains bool `yaml:"hsts-include-subdomains"` + 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"` + 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. @@ -157,14 +157,17 @@ func parseCipherSuites(names []string) ([]uint16, error) { // https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/crypto/tls/cipher_suites.go var suites []uint16 - for _, cs := range tls.CipherSuites() { - for _, name := range names { + for _, name := range names { + var cipherSuiteSupported bool = false + for _, cs := range tls.CipherSuites() { if cs.Name == name { suites = append(suites, cs.ID) - } else { - return nil, errgo.Newf("Unknown cipher suite name: %s", name) + cipherSuiteSupported = true } } + if !cipherSuiteSupported { + return nil, errgo.Newf("Unsupported cipher suite: %s", name) + } } return suites, nil } @@ -209,6 +212,9 @@ func (c *Config) validate() error { if c.ListenAddress == "" { missing = append(missing, "listen-address") } + if c.HSTSIncludeSubdomains == true && 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 ccb86998..1f6dba96 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -90,6 +90,93 @@ 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 @@ -123,10 +210,17 @@ func TestRead(t *testing.T) { idp.Register("usso", testIdentityProvider) idp.Register("keystone", testIdentityProvider) store.Register("test", testStorageBackend) - conf, err := readConfig(c, testConfig) + + // check if wrong cipher suite names are detected + conf, err := 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 = "" @@ -165,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", From 069e1cac6f5f25ef99b211a9511918823e540c4d Mon Sep 17 00:00:00 2001 From: Thorsten Date: Fri, 16 Jan 2026 13:59:41 +0100 Subject: [PATCH 03/10] docs: make HSTS docs more explicit --- config/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 120f5ba9..d42f84c9 100644 --- a/config/config.go +++ b/config/config.go @@ -66,7 +66,8 @@ type Config struct { HSTSMaxAge int `yaml:"HSTS-max-age"` // HSTSIncludeSubdomains controls whether the includeSubDomains directive - // is added to the HSTS header. + // 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. From cb95fda093d7cc278aae2a5f3b153c4402763cdf Mon Sep 17 00:00:00 2001 From: Thorsten Date: Mon, 26 Jan 2026 15:54:01 +0100 Subject: [PATCH 04/10] test: add initial testing of TLS configurability not very clean yet, mostly generated and fixed rough edges. Needs to be refactored dry --- candidtest/candidtest.go | 101 +++++++++++++++++ server_cipher_test.go | 237 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 server_cipher_test.go diff --git a/candidtest/candidtest.go b/candidtest/candidtest.go index c03996e9..d5130d27 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,94 @@ 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) +// srv.TLS = tlsConfig +// srv.StartTLS() +// return srv +// } + +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/server_cipher_test.go b/server_cipher_test.go new file mode 100644 index 00000000..5a736082 --- /dev/null +++ b/server_cipher_test.go @@ -0,0 +1,237 @@ +// Copyright 2026 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package candid_test + +import ( + "crypto/tls" + "fmt" + "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" +) + +// TestServerTLSCipherSuites tests that: +// 1. The server can be configured with specific TLS cipher suites +// 2. The server only accepts those configured ciphers +// 3. The server is accessible via HTTP/2 +// and uses TLS1.2 as for TLS1.3 ciphers cannot be configured +// 4. All configured ciphers are available +func TestServerTLSCipherSuites(t *testing.T) { + c := qt.New(t) + + configuredCiphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + } + + // Generate a test certificate and key + key, err := bakery.GenerateKey() + c.Assert(err, qt.IsNil) + + // Create a self-signed certificate for testing + cert, certPEM, keyPEM, err := candidtest.GenerateTestCert("localhost") + c.Assert(err, qt.IsNil) + + // Create server parameters + 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", + } + + // Create a custom TLS configuration with specific cipher suites + 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"}, + } + + // Convert cipher suite names to their numeric IDs + 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 + + // Create an HTTPS server with the handler and TLS config + 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() + + // Create a client that will verify cipher suites + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + // Force HTTP/2 + ForceAttemptHTTP2: true, + }, + } + + // Connect to the server and get connection state + resp, err := client.Get(srv.URL + "/v1/discharge") + c.Assert(err, qt.IsNil) + defer resp.Body.Close() + + // Get the TLS connection state from the response + c.Assert(resp.TLS, qt.Not(qt.IsNil), qt.Commentf("expected TLS connection")) + + // Verify the protocol is HTTP/2 + 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 + fmt.Printf("Server selected cipher suite name %s and ID: %d\n", tls.CipherSuiteName(usedCipherID), usedCipherID) + fmt.Printf("Configured cipher suite names %s and IDs: %v\n", configuredCiphers, cipherSuiteIDs) + 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, + )) + + // Verify all configured ciphers are actually usable by creating multiple connections + usedCiphers := make(map[uint16]bool) + for i := 0; i < 5; i++ { + resp, err := client.Get(srv.URL + "/v1/discharge") + c.Assert(err, qt.IsNil) + c.Assert(resp.TLS, qt.Not(qt.IsNil)) + usedCiphers[resp.TLS.CipherSuite] = true + resp.Body.Close() + } + + // Verify at least one cipher from our configured set was used + c.Assert(len(usedCiphers) > 0, qt.IsTrue, qt.Commentf("no ciphers were used")) + + // All used ciphers should be in our configured set + for usedCipherID := range usedCiphers { + 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, + )) + } +} + +// TestServerTLSCipherSuitesRestriction tests that unconfigured ciphers +// cannot be used even if the client requests them +func TestServerTLSCipherSuitesRestriction(t *testing.T) { + c := qt.New(t) + + // Define only one cipher suite to be available + configuredCiphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + } + + // Generate a test certificate and key + key, err := bakery.GenerateKey() + c.Assert(err, qt.IsNil) + + // Create a self-signed certificate for testing + cert, certPEM, keyPEM, err := candidtest.GenerateTestCert("localhost") + c.Assert(err, qt.IsNil) + + // Create server parameters + 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", + } + + // Create a custom TLS configuration with specific cipher suites + 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"}, + } + + // Convert cipher suite names to their numeric IDs + var cipherSuiteIDs []uint16 + for _, cipherName := range configuredCiphers { + for _, cs := range tls.CipherSuites() { + if cs.Name == cipherName { + cipherSuiteIDs = append(cipherSuiteIDs, cs.ID) + break + } + } + } + c.Assert(cipherSuiteIDs, qt.HasLen, len(configuredCiphers)) + tlsConfig.CipherSuites = cipherSuiteIDs + + // Create the server + handler, err := candid.NewServer(params, candid.V1) + c.Assert(err, qt.IsNil) + defer handler.Close() + + // Create an HTTPS server with the handler and TLS config + srv := candidtest.NewTLSServerWithConfig(handler, tlsConfig, certPEM, keyPEM) + defer srv.Close() + + // 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, + // Request multiple ciphers, but server should use its preferred one + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + }, + PreferServerCipherSuites: false, + } + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: clientTLSConfig, + ForceAttemptHTTP2: true, + }, + } + + resp, err := client.Get(srv.URL + "/v1/discharge") + c.Assert(err, qt.IsNil) + defer resp.Body.Close() + + // Verify that the cipher suite used is the one configured on the server + c.Assert(resp.TLS.CipherSuite, qt.Equals, cipherSuiteIDs[0], qt.Commentf( + "expected server-configured cipher %d, got %d", + cipherSuiteIDs[0], resp.TLS.CipherSuite, + )) +} From 3ec4a75e657cec37feff8d871dec6a3df8bfb124 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 28 Jan 2026 18:24:04 +0100 Subject: [PATCH 05/10] test: add test to check if configured ciphers are loaded and checked --- server_cipher_test.go | 141 ++++++------------------------------------ 1 file changed, 19 insertions(+), 122 deletions(-) diff --git a/server_cipher_test.go b/server_cipher_test.go index 5a736082..f01de548 100644 --- a/server_cipher_test.go +++ b/server_cipher_test.go @@ -1,6 +1,9 @@ // 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 ( @@ -19,29 +22,15 @@ import ( "github.com/canonical/candid/store/memstore" ) -// TestServerTLSCipherSuites tests that: -// 1. The server can be configured with specific TLS cipher suites -// 2. The server only accepts those configured ciphers -// 3. The server is accessible via HTTP/2 -// and uses TLS1.2 as for TLS1.3 ciphers cannot be configured -// 4. All configured ciphers are available -func TestServerTLSCipherSuites(t *testing.T) { +func serverTLSCipherSuitesRunner(t *testing.T, configuredCiphers []string, clientTLSConfig *tls.Config) { c := qt.New(t) - configuredCiphers := []string{ - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - } - - // Generate a test certificate and key + // Generate a test certificate, key, and self-sign key, err := bakery.GenerateKey() c.Assert(err, qt.IsNil) - - // Create a self-signed certificate for testing cert, certPEM, keyPEM, err := candidtest.GenerateTestCert("localhost") c.Assert(err, qt.IsNil) - // Create server parameters params := candid.ServerParams{ Store: memstore.NewStore(), MeetingStore: memstore.NewMeetingStore(), @@ -52,7 +41,6 @@ func TestServerTLSCipherSuites(t *testing.T) { Location: "https://localhost", } - // Create a custom TLS configuration with specific cipher suites tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, @@ -60,7 +48,6 @@ func TestServerTLSCipherSuites(t *testing.T) { NextProtos: []string{"h2", "http/1.1"}, } - // Convert cipher suite names to their numeric IDs var cipherSuiteIDs []uint16 for _, cipherName := range configuredCiphers { for _, cs := range tls.CipherSuites() { @@ -72,33 +59,26 @@ func TestServerTLSCipherSuites(t *testing.T) { } tlsConfig.CipherSuites = cipherSuiteIDs - // Create an HTTPS server with the handler and TLS config + // 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() - // Create a client that will verify cipher suites client := &http.Client{ Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - // Force HTTP/2 + TLSClientConfig: clientTLSConfig, ForceAttemptHTTP2: true, }, } - // Connect to the server and get connection state resp, err := client.Get(srv.URL + "/v1/discharge") c.Assert(err, qt.IsNil) defer resp.Body.Close() - // Get the TLS connection state from the response c.Assert(resp.TLS, qt.Not(qt.IsNil), qt.Commentf("expected TLS connection")) - // Verify the protocol is HTTP/2 c.Assert(resp.Proto, qt.Equals, "HTTP/2.0") // Verify that the cipher suite used is one of the configured ones @@ -116,122 +96,39 @@ func TestServerTLSCipherSuites(t *testing.T) { "cipher suite %d not in configured suites: %v", usedCipherID, cipherSuiteIDs, )) +} - // Verify all configured ciphers are actually usable by creating multiple connections - usedCiphers := make(map[uint16]bool) - for i := 0; i < 5; i++ { - resp, err := client.Get(srv.URL + "/v1/discharge") - c.Assert(err, qt.IsNil) - c.Assert(resp.TLS, qt.Not(qt.IsNil)) - usedCiphers[resp.TLS.CipherSuite] = true - resp.Body.Close() +func TestServerTLSCipherSuites(t *testing.T) { + configuredCiphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", } - // Verify at least one cipher from our configured set was used - c.Assert(len(usedCiphers) > 0, qt.IsTrue, qt.Commentf("no ciphers were used")) - - // All used ciphers should be in our configured set - for usedCipherID := range usedCiphers { - 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, - )) + clientTLSConfig := &tls.Config{ + InsecureSkipVerify: true, } + + serverTLSCipherSuitesRunner(t, configuredCiphers, clientTLSConfig) } -// TestServerTLSCipherSuitesRestriction tests that unconfigured ciphers -// cannot be used even if the client requests them func TestServerTLSCipherSuitesRestriction(t *testing.T) { - c := qt.New(t) - // Define only one cipher suite to be available configuredCiphers := []string{ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", } - // Generate a test certificate and key - key, err := bakery.GenerateKey() - c.Assert(err, qt.IsNil) - - // Create a self-signed certificate for testing - cert, certPEM, keyPEM, err := candidtest.GenerateTestCert("localhost") - c.Assert(err, qt.IsNil) - - // Create server parameters - 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", - } - - // Create a custom TLS configuration with specific cipher suites - 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"}, - } - - // Convert cipher suite names to their numeric IDs - var cipherSuiteIDs []uint16 - for _, cipherName := range configuredCiphers { - for _, cs := range tls.CipherSuites() { - if cs.Name == cipherName { - cipherSuiteIDs = append(cipherSuiteIDs, cs.ID) - break - } - } - } - c.Assert(cipherSuiteIDs, qt.HasLen, len(configuredCiphers)) - tlsConfig.CipherSuites = cipherSuiteIDs - - // Create the server - handler, err := candid.NewServer(params, candid.V1) - c.Assert(err, qt.IsNil) - defer handler.Close() - - // Create an HTTPS server with the handler and TLS config - srv := candidtest.NewTLSServerWithConfig(handler, tlsConfig, certPEM, keyPEM) - defer srv.Close() - // 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, - // Request multiple ciphers, but server should use its preferred one CipherSuites: []uint16{ + // prefer unconfigured cipher suites tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, PreferServerCipherSuites: false, } - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: clientTLSConfig, - ForceAttemptHTTP2: true, - }, - } - - resp, err := client.Get(srv.URL + "/v1/discharge") - c.Assert(err, qt.IsNil) - defer resp.Body.Close() - - // Verify that the cipher suite used is the one configured on the server - c.Assert(resp.TLS.CipherSuite, qt.Equals, cipherSuiteIDs[0], qt.Commentf( - "expected server-configured cipher %d, got %d", - cipherSuiteIDs[0], resp.TLS.CipherSuite, - )) + serverTLSCipherSuitesRunner(t, configuredCiphers, clientTLSConfig) } From 15c1879ebf0f0d17ff8b47fb9e26631ed601a85a Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 28 Jan 2026 19:39:43 +0100 Subject: [PATCH 06/10] docs: update docs to include HSTS and cipher suite settings - update docs - update example config --- docs/configuration.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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 From 0dbe36b8ff95cfae2e2d297c04335c6611324f02 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 28 Jan 2026 20:18:16 +0100 Subject: [PATCH 07/10] fix: fix linter complaints --- config/config.go | 4 ++-- config/config_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/config.go b/config/config.go index d42f84c9..e2d8e353 100644 --- a/config/config.go +++ b/config/config.go @@ -159,7 +159,7 @@ func parseCipherSuites(names []string) ([]uint16, error) { var suites []uint16 for _, name := range names { - var cipherSuiteSupported bool = false + var cipherSuiteSupported = false for _, cs := range tls.CipherSuites() { if cs.Name == name { suites = append(suites, cs.ID) @@ -213,7 +213,7 @@ func (c *Config) validate() error { if c.ListenAddress == "" { missing = append(missing, "listen-address") } - if c.HSTSIncludeSubdomains == true && c.HSTSMaxAge == 0 { + if c.HSTSIncludeSubdomains && c.HSTSMaxAge == 0 { missing = append(missing, "HSTS-max-age (required when HSTS-include-subdomains is true)") } if c.PrivateKey == nil { diff --git a/config/config_test.go b/config/config_test.go index 1f6dba96..7b8fe654 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -212,12 +212,12 @@ func TestRead(t *testing.T) { store.Register("test", testStorageBackend) // check if wrong cipher suite names are detected - conf, err := readConfig(c, testConfigWithUnsupportedCipher) + conf, _ := readConfig(c, testConfigWithUnsupportedCipher) tlsConfig := conf.TLSConfig() c.Assert(tlsConfig, qt.IsNil) // continue with valid config - conf, err = readConfig(c, testConfig) + conf, err := readConfig(c, testConfig) c.Assert(err, qt.IsNil) // Check that the TLS configuration creates a valid *tls.Config tlsConfig = conf.TLSConfig() From e2044f7db914b77b45a90c4a805616c15e95e5ac Mon Sep 17 00:00:00 2001 From: Thorsten Date: Mon, 2 Feb 2026 17:57:48 +0100 Subject: [PATCH 08/10] refactor: linter and review comments - brush up some comments - remove commented code - remove maxAge check in middleware - make HSTS string constants, fix max-age format - remove debug print statements in test --- candidtest/candidtest.go | 7 ------- cmd/candidsrv/main.go | 21 ++++++++++++--------- config/config.go | 8 ++++---- server_cipher_test.go | 5 +---- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/candidtest/candidtest.go b/candidtest/candidtest.go index d5130d27..f550a965 100644 --- a/candidtest/candidtest.go +++ b/candidtest/candidtest.go @@ -206,13 +206,6 @@ func GenerateTestCert(commonName string) (tls.Certificate, []byte, []byte, error // 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) -// srv.TLS = tlsConfig -// srv.StartTLS() -// return srv -// } - func NewTLSServerWithConfig(handler http.Handler, tlsConfig *tls.Config, certPEM, keyPEM []byte) *httptest.Server { srv := httptest.NewUnstartedServer(handler) diff --git a/cmd/candidsrv/main.go b/cmd/candidsrv/main.go index f7c8ccf5..7140d764 100644 --- a/cmd/candidsrv/main.go +++ b/cmd/candidsrv/main.go @@ -114,17 +114,20 @@ func serve(conf *config.Config) error { }) } -// hstsMiddleware adds HSTS headers when configured +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) { - if maxAge > 0 { - headerParams := fmt.Sprintf("max-age: %d", maxAge) - if includeSubDomains { - // Capital 'S and D' per RFC 6797 - headerParams += "; includeSubDomains" - } - w.Header().Add("Strict-Transport-Security", headerParams) + 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) }) } @@ -187,7 +190,7 @@ 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 + // Add HSTS middleware if configured. if conf.HSTSMaxAge > 0 { server = hstsMiddleware(server, conf.HSTSMaxAge, conf.HSTSIncludeSubdomains) } diff --git a/config/config.go b/config/config.go index e2d8e353..e92daaa9 100644 --- a/config/config.go +++ b/config/config.go @@ -72,7 +72,7 @@ type Config struct { // 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") + // 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 @@ -154,7 +154,7 @@ type Config struct { } func parseCipherSuites(names []string) ([]uint16, error) { - // this list is inspired by golangs current cypher suite prioritization + // 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 @@ -175,8 +175,8 @@ func parseCipherSuites(names []string) ([]uint16, error) { // 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 +// 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 diff --git a/server_cipher_test.go b/server_cipher_test.go index f01de548..74dade2c 100644 --- a/server_cipher_test.go +++ b/server_cipher_test.go @@ -8,7 +8,6 @@ package candid_test import ( "crypto/tls" - "fmt" "net/http" "testing" @@ -75,7 +74,7 @@ func serverTLSCipherSuitesRunner(t *testing.T, configuredCiphers []string, clien resp, err := client.Get(srv.URL + "/v1/discharge") c.Assert(err, qt.IsNil) - defer resp.Body.Close() + defer func() { c.Assert(resp.Body.Close(), qt.IsNil) }() c.Assert(resp.TLS, qt.Not(qt.IsNil), qt.Commentf("expected TLS connection")) @@ -83,8 +82,6 @@ func serverTLSCipherSuitesRunner(t *testing.T, configuredCiphers []string, clien // Verify that the cipher suite used is one of the configured ones usedCipherID := resp.TLS.CipherSuite - fmt.Printf("Server selected cipher suite name %s and ID: %d\n", tls.CipherSuiteName(usedCipherID), usedCipherID) - fmt.Printf("Configured cipher suite names %s and IDs: %v\n", configuredCiphers, cipherSuiteIDs) found := false for _, id := range cipherSuiteIDs { if id == usedCipherID { From a18ec7b181080d8c1e55aa22f64d61f0d9ee86bb Mon Sep 17 00:00:00 2001 From: Thorsten Merten <3410703+tmerten@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:18:57 +0100 Subject: [PATCH 09/10] refactor: remove commented code Co-authored-by: Gabor Borics-Kuerti --- candidtest/candidtest.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/candidtest/candidtest.go b/candidtest/candidtest.go index f550a965..15795577 100644 --- a/candidtest/candidtest.go +++ b/candidtest/candidtest.go @@ -197,13 +197,6 @@ func GenerateTestCert(commonName string) (tls.Certificate, []byte, []byte, error // 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 { From 2043a0088f43c6524e637818eb8c296d1f61e256 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Mon, 2 Feb 2026 19:48:17 +0100 Subject: [PATCH 10/10] fix: double deletion --- candidtest/candidtest.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/candidtest/candidtest.go b/candidtest/candidtest.go index 15795577..f550a965 100644 --- a/candidtest/candidtest.go +++ b/candidtest/candidtest.go @@ -197,6 +197,13 @@ func GenerateTestCert(commonName string) (tls.Certificate, []byte, []byte, error // 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 {