diff --git a/README.md b/README.md index d89f5d90c..fae813091 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ Usage of ./observatorium-api: Comma-separated list of cipher suites for the server. Values are from tls package constants (https://golang.org/pkg/crypto/tls/#pkg-constants). If omitted, the default Go cipher suites will be used. Note that TLS 1.3 ciphersuites are not configurable. -tls.client-auth-type string Policy for TLS client-side authentication. Values are from ClientAuthType constants in https://pkg.go.dev/crypto/tls#ClientAuthType (default "RequestClientCert") + -tls.curve-preferences string + Comma-separated list of key exchange groups for the server. Values are IANA "TLS Supported Groups" names (e.g. X25519, secp256r1, X25519MLKEM768); Go crypto/tls constant names (e.g. CurveP256) are also accepted for the classic curves. If omitted, the default Go groups will be used. The list is a filter of allowed groups; crypto/tls chooses the preference order. -tls.healthchecks.server-ca-file string File containing the TLS CA against which to verify servers. If no server CA is specified, the client will use the system certificates. -tls.healthchecks.server-name string diff --git a/main.go b/main.go index af2ff1c78..217ff0c4e 100644 --- a/main.go +++ b/main.go @@ -126,11 +126,12 @@ type serverConfig struct { } type tlsConfig struct { - minVersion string - maxVersion string - cipherSuites []string - clientAuthType string - reloadInterval time.Duration + minVersion string + maxVersion string + cipherSuites []string + curvePreferences []string + clientAuthType string + reloadInterval time.Duration serverCertFile string serverKeyFile string @@ -894,6 +895,7 @@ func main() { cfg.tls.maxVersion, cfg.tls.clientAuthType, cfg.tls.cipherSuites, + cfg.tls.curvePreferences, ) if err != nil { stdlog.Fatalf("failed to initialize tls config: %v", err) @@ -1004,6 +1006,7 @@ func main() { cfg.tls.maxVersion, cfg.tls.clientAuthType, cfg.tls.cipherSuites, + cfg.tls.curvePreferences, ) if err != nil { stdlog.Fatalf("failed to initialize tls config: %v", err) @@ -1108,6 +1111,7 @@ func (m *multiStringFlag) String() string { func parseFlags() (config, error) { var ( rawTLSCipherSuites string + rawTLSCurvePreferences string rawMetricsReadEndpoint string rawMetricsWriteEndpoint string rawMetricsRulesEndpoint string @@ -1277,6 +1281,12 @@ func parseFlags() (config, error) { " Values are from tls package constants (https://golang.org/pkg/crypto/tls/#pkg-constants)."+ " If omitted, the default Go cipher suites will be used."+ " Note that TLS 1.3 ciphersuites are not configurable.") + flag.StringVar(&rawTLSCurvePreferences, "tls.curve-preferences", "", + "Comma-separated list of key exchange groups for the server."+ + " Values are IANA \"TLS Supported Groups\" names (e.g. X25519, secp256r1, X25519MLKEM768);"+ + " Go crypto/tls constant names (e.g. CurveP256) are also accepted for the classic curves."+ + " If omitted, the default Go groups will be used."+ + " The list is a filter of allowed groups; crypto/tls chooses the preference order.") flag.StringVar(&cfg.tls.clientAuthType, "tls.client-auth-type", "RequestClientCert", "Policy for TLS client-side authentication. Values are from ClientAuthType constants in https://pkg.go.dev/crypto/tls#ClientAuthType") flag.DurationVar(&cfg.tls.reloadInterval, "tls.reload-interval", time.Minute, @@ -1485,6 +1495,9 @@ func parseFlags() (config, error) { if rawTLSCipherSuites != "" { cfg.tls.cipherSuites = strings.Split(rawTLSCipherSuites, ",") } + if rawTLSCurvePreferences != "" { + cfg.tls.curvePreferences = strings.Split(rawTLSCurvePreferences, ",") + } return cfg, nil } @@ -1618,6 +1631,7 @@ func newGRPCServer(cfg *config, tenantHeader string, tenantIDs map[string]string cfg.tls.maxVersion, cfg.tls.clientAuthType, cfg.tls.cipherSuites, + cfg.tls.curvePreferences, ) if err != nil { return nil, fmt.Errorf("failed to create gRPC TLS config: %w", err) diff --git a/tls/config.go b/tls/config.go index bac2d0a06..a0d393107 100644 --- a/tls/config.go +++ b/tls/config.go @@ -8,8 +8,26 @@ import ( "github.com/go-kit/log/level" ) +// curveIDs maps supported key-exchange group names to crypto/tls CurveIDs. +// Both IANA names and Go crypto/tls constant names are accepted. +var curveIDs = map[string]tls.CurveID{ + // X25519 and the ML-KEM hybrids: IANA name == Go constant name. + "X25519": tls.X25519, + "X25519MLKEM768": tls.X25519MLKEM768, + "SecP256r1MLKEM768": tls.SecP256r1MLKEM768, + "SecP384r1MLKEM1024": tls.SecP384r1MLKEM1024, + + // Classic EC curves: IANA name (preferred) and Go constant name (alias). + "secp256r1": tls.CurveP256, + "secp384r1": tls.CurveP384, + "secp521r1": tls.CurveP521, + "CurveP521": tls.CurveP521, + "CurveP256": tls.CurveP256, + "CurveP384": tls.CurveP384, +} + // NewServerConfig provides new server TLS configuration. -func NewServerConfig(logger log.Logger, certFile, keyFile, minVersion, maxVersion, clientAuthType string, cipherSuites []string) (*tls.Config, error) { +func NewServerConfig(logger log.Logger, certFile, keyFile, minVersion, maxVersion, clientAuthType string, cipherSuites, curvePreferences []string) (*tls.Config, error) { if certFile == "" && keyFile == "" { level.Info(logger).Log("msg", "TLS disabled; key and cert must be set to enable") @@ -42,6 +60,11 @@ func NewServerConfig(logger log.Logger, certFile, keyFile, minVersion, maxVersio return nil, fmt.Errorf("TLS cipher suite name to ID conversion: %v", err) } + curvePreferenceIDs, err := mapCurveNamesToIDs(curvePreferences) + if err != nil { + return nil, fmt.Errorf("TLS curve preference name to ID conversion: %v", err) + } + tlsClientAuthType, err := parseClientAuthType(clientAuthType) if err != nil { return nil, fmt.Errorf("can not parse TLS Client authentication policy: %w", err) @@ -53,9 +76,11 @@ func NewServerConfig(logger log.Logger, certFile, keyFile, minVersion, maxVersio // If CipherSuites is nil, a default list of secure cipher suites is used. // Note that TLS 1.3 ciphersuites are not configurable. CipherSuites: cipherSuiteIDs, - ClientAuth: tlsClientAuthType, - MinVersion: tlsMinVersion, - MaxVersion: tlsMaxVersion, + // If CurvePreferences is nil, a default list of secure curves is used. + CurvePreferences: curvePreferenceIDs, + ClientAuth: tlsClientAuthType, + MinVersion: tlsMinVersion, + MaxVersion: tlsMaxVersion, } return tlsCfg, nil @@ -108,6 +133,25 @@ func mapCipherNamesToIDs(rawTLSCipherSuites []string) ([]uint16, error) { return cipherSuites, nil } +func mapCurveNamesToIDs(rawTLSCurvePreferences []string) ([]tls.CurveID, error) { + if rawTLSCurvePreferences == nil { + return nil, nil + } + + curvePreferences := []tls.CurveID{} + + for _, name := range rawTLSCurvePreferences { + id, ok := curveIDs[name] + if !ok { + return nil, fmt.Errorf("unknown TLSCurve: %s", name) + } + + curvePreferences = append(curvePreferences, id) + } + + return curvePreferences, nil +} + func parseClientAuthType(rawAuthType string) (tls.ClientAuthType, error) { switch rawAuthType { case "NoClientCert": diff --git a/tls/config_test.go b/tls/config_test.go new file mode 100644 index 000000000..88df557af --- /dev/null +++ b/tls/config_test.go @@ -0,0 +1,147 @@ +package tls + +import ( + "crypto/tls" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/observatorium/api/logger" +) + +func TestMapCurveNamesToIDs(t *testing.T) { + tests := []struct { + name string + input []string + want []tls.CurveID + errContain string + }{ + { + name: "nil input", + input: nil, + want: nil, + }, + { + name: "all supported names (IANA and Go constant)", + input: []string{ + // X25519 and the ML-KEM hybrids: IANA name == Go constant name. + "X25519", + "X25519MLKEM768", + "SecP256r1MLKEM768", + "SecP384r1MLKEM1024", + // Classic EC curves: IANA name and Go constant name. + "secp256r1", "CurveP256", + "secp384r1", "CurveP384", + "secp521r1", "CurveP521", + }, + want: []tls.CurveID{ + tls.X25519, + tls.X25519MLKEM768, + tls.SecP256r1MLKEM768, + tls.SecP384r1MLKEM1024, + tls.CurveP256, tls.CurveP256, + tls.CurveP384, tls.CurveP384, + tls.CurveP521, tls.CurveP521, + }, + }, + { + name: "mixed IANA and Go constant names", + input: []string{"secp256r1", "CurveP384"}, + want: []tls.CurveID{tls.CurveP256, tls.CurveP384}, + }, + { + name: "unknown curve name", + input: []string{"CurveUnknown"}, + errContain: "unknown TLSCurve: CurveUnknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := mapCurveNamesToIDs(tc.input) + if tc.errContain != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.errContain) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestNewServerConfigCurvePreferences(t *testing.T) { + certPath, keyPath, cleanCerts, err := newSelfSignedCert("localhost") + require.NoError(t, err) + defer cleanCerts() + + l := logger.NewLogger("info", logger.LogFormatLogfmt, "") + + tests := []struct { + name string + curves []string + want []tls.CurveID + errContains []string + }{ + { + name: "omitted uses default curves", + curves: nil, + want: nil, + }, + { + name: "configured curve preferences", + curves: []string{ + "X25519MLKEM768", + "X25519", + "secp256r1", + "CurveP384", + "secp521r1", + "SecP256r1MLKEM768", + "SecP384r1MLKEM1024", + }, + want: []tls.CurveID{ + tls.X25519MLKEM768, + tls.X25519, + tls.CurveP256, + tls.CurveP384, + tls.CurveP521, + tls.SecP256r1MLKEM768, + tls.SecP384r1MLKEM1024, + }, + }, + { + name: "invalid curve preferences", + curves: []string{"CurveUnknown"}, + errContains: []string{ + "TLS curve preference name to ID conversion", + "unknown TLSCurve: CurveUnknown", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, err := NewServerConfig( + l, + certPath, + keyPath, + "VersionTLS13", + "VersionTLS13", + "RequestClientCert", + nil, + tc.curves, + ) + if len(tc.errContains) > 0 { + require.Error(t, err) + for _, msg := range tc.errContains { + require.ErrorContains(t, err, msg) + } + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, cfg.CurvePreferences) + }) + } +}