diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index 77930754b..93e319f6a 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -182,7 +182,9 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err // ACME - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(staticConfiguration.OCSP) + routinesPool.GoCtx(tlsManager.Run) + httpChallengeProvider := acme.NewChallengeHTTP() tlsChallengeProvider := acme.NewChallengeTLSALPN() diff --git a/docs/content/https/ocsp.md b/docs/content/https/ocsp.md new file mode 100644 index 000000000..a960d88d0 --- /dev/null +++ b/docs/content/https/ocsp.md @@ -0,0 +1,71 @@ +--- +title: "Traefik OCSP Documentation" +description: "Learn how to configure Traefik to use OCSP. Read the technical documentation." +--- + +# OCSP + +Check certificate status and perform OCSP stapling. +{: .subtitle } + +## Overview + +### OCSP Stapling + +When OCSP is enabled, Traefik checks the status of every certificate in the store that provides an OCSP responder URL, +including the default certificate, and staples the OCSP response to the TLS handshake. +The OCSP check is performed when the certificate is loaded, +and once every hour until it is successful at the halfway point before the update date. + +### Caching + +Traefik caches the OCSP response as long as the associated certificate is provided by the configuration. +When a certificate is no longer provided, +the OCSP response has a 24 hour TTL waiting to be provided again or eventually removed. +The OCSP response is cached in memory and is not persisted between Traefik restarts. + +## Configuration + +### General + +Enabling OCSP is part of the [static configuration](../getting-started/configuration-overview.md#the-static-configuration). +It can be defined by using a file (YAML or TOML) or CLI arguments: + +```yaml tab="File (YAML)" +## Static configuration +ocsp: {} +``` + +```toml tab="File (TOML)" +## Static configuration +[ocsp] +``` + +```bash tab="CLI" +## Static configuration +--ocsp=true +``` + +### Responder Overrides + +The `responderOverrides` option defines the OCSP responder URLs to use instead of the one provided by the certificate. +This is useful when you want to use a different OCSP responder. + +```yaml tab="File (YAML)" +## Static configuration +ocsp: + responderOverrides: + foo: bar +``` + +```toml tab="File (TOML)" +## Static configuration +[ocsp] + [ocsp.responderOverrides] + foo = "bar" +``` + +```bash tab="CLI" +## Static configuration +-ocsp.responderoverrides.foo=bar +``` diff --git a/docs/content/reference/install-configuration/tls/ocsp.md b/docs/content/reference/install-configuration/tls/ocsp.md new file mode 100644 index 000000000..a960d88d0 --- /dev/null +++ b/docs/content/reference/install-configuration/tls/ocsp.md @@ -0,0 +1,71 @@ +--- +title: "Traefik OCSP Documentation" +description: "Learn how to configure Traefik to use OCSP. Read the technical documentation." +--- + +# OCSP + +Check certificate status and perform OCSP stapling. +{: .subtitle } + +## Overview + +### OCSP Stapling + +When OCSP is enabled, Traefik checks the status of every certificate in the store that provides an OCSP responder URL, +including the default certificate, and staples the OCSP response to the TLS handshake. +The OCSP check is performed when the certificate is loaded, +and once every hour until it is successful at the halfway point before the update date. + +### Caching + +Traefik caches the OCSP response as long as the associated certificate is provided by the configuration. +When a certificate is no longer provided, +the OCSP response has a 24 hour TTL waiting to be provided again or eventually removed. +The OCSP response is cached in memory and is not persisted between Traefik restarts. + +## Configuration + +### General + +Enabling OCSP is part of the [static configuration](../getting-started/configuration-overview.md#the-static-configuration). +It can be defined by using a file (YAML or TOML) or CLI arguments: + +```yaml tab="File (YAML)" +## Static configuration +ocsp: {} +``` + +```toml tab="File (TOML)" +## Static configuration +[ocsp] +``` + +```bash tab="CLI" +## Static configuration +--ocsp=true +``` + +### Responder Overrides + +The `responderOverrides` option defines the OCSP responder URLs to use instead of the one provided by the certificate. +This is useful when you want to use a different OCSP responder. + +```yaml tab="File (YAML)" +## Static configuration +ocsp: + responderOverrides: + foo: bar +``` + +```toml tab="File (TOML)" +## Static configuration +[ocsp] + [ocsp.responderOverrides] + foo = "bar" +``` + +```bash tab="CLI" +## Static configuration +-ocsp.responderoverrides.foo=bar +``` diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index cd98f99af..e0b1b0c73 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -642,6 +642,12 @@ Prefix to use for metrics collection. (Default: ```traefik```) `--metrics.statsd.pushinterval`: StatsD push interval. (Default: ```10```) +`--ocsp`: +OCSP configuration. (Default: ```false```) + +`--ocsp.responderoverrides.`: +Defines a map of OCSP responders to replace for querying OCSP servers. + `--ping`: Enable ping. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 4eac71881..ca423ab23 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -642,6 +642,12 @@ Prefix to use for metrics collection. (Default: ```traefik```) `TRAEFIK_METRICS_STATSD_PUSHINTERVAL`: StatsD push interval. (Default: ```10```) +`TRAEFIK_OCSP`: +OCSP configuration. (Default: ```false```) + +`TRAEFIK_OCSP_RESPONDEROVERRIDES_`: +Defines a map of OCSP responders to replace for querying OCSP servers. + `TRAEFIK_PING`: Enable ping. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 4e9e45ebe..4839306e8 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -609,3 +609,8 @@ [spiffe] workloadAPIAddr = "foobar" + +[ocsp] + [ocsp.responderOverrides] + name0 = "foobar" + name1 = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index 77310bd39..4f302ead3 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -675,3 +675,7 @@ core: defaultRuleSyntax: foobar spiffe: workloadAPIAddr: foobar +ocsp: + responderOverrides: + name0: foobar + name1: foobar diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d8d50cd10..587b0a3c0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -110,6 +110,7 @@ nav: - 'Let''s Encrypt': 'https/acme.md' - 'Tailscale': 'https/tailscale.md' - 'SPIFFE': 'https/spiffe.md' + - 'OCSP': 'https/ocsp.md' - 'Middlewares': - 'Overview': 'middlewares/overview.md' - 'HTTP': @@ -227,6 +228,7 @@ nav: - "ACME" : 'reference/install-configuration/tls/certificate-resolvers/acme.md' - "Tailscale" : 'reference/install-configuration/tls/certificate-resolvers/tailscale.md' - "SPIFFE" : 'reference/install-configuration/tls/spiffe.md' + - "OCSP" : 'reference/install-configuration/tls/ocsp.md' - 'Observability': - 'Metrics' : 'reference/install-configuration/observability/metrics.md' - 'Tracing': 'reference/install-configuration/observability/tracing.md' diff --git a/go.mod b/go.mod index 8f2e12868..e810e3120 100644 --- a/go.mod +++ b/go.mod @@ -95,6 +95,7 @@ require ( go.opentelemetry.io/otel/sdk/log v0.8.0 go.opentelemetry.io/otel/sdk/metric v1.34.0 go.opentelemetry.io/otel/trace v1.34.0 + golang.org/x/crypto v0.37.0 golang.org/x/mod v0.23.0 golang.org/x/net v0.39.0 golang.org/x/sync v0.13.0 @@ -366,7 +367,6 @@ require ( go.uber.org/ratelimit v0.3.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.4.0 // indirect - golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/term v0.31.0 // indirect diff --git a/integration/fixtures/ocsp/ca.crt b/integration/fixtures/ocsp/ca.crt new file mode 100644 index 000000000..bc620de75 --- /dev/null +++ b/integration/fixtures/ocsp/ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBhjCCASygAwIBAgIBATAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdUZXN0IENB +MB4XDTI1MDQyNDEzNTIzOFoXDTM1MDQyMjEzNTIzOFowEjEQMA4GA1UEAxMHVGVz +dCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPp8MoNUBbUxp3jW6FcDH+lg +Zft1SIpnGjkMVjLSbW9EzmRQ/oMRHQqJvE7wJbwDs/JUTigRtfZL0vOojnhHcPej +czBxMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQW +BBQXRlWLK295lmDy+931a4Ha8XVNNjAsBggrBgEFBQcBAQQgMB4wHAYIKwYBBQUH +MAGGEG9jc3AuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSAAwRQIgYH6lnce9jxcp +YIVhY4z55rnOKXqaI/5rUQKwjJ3dRsUCIQDThtkFgOPT/67xOYCTCEVSMSTwh2Gq +jbeucU+4c/InVg== +-----END CERTIFICATE----- diff --git a/integration/fixtures/ocsp/ca.key b/integration/fixtures/ocsp/ca.key new file mode 100644 index 000000000..ac7b3aa04 --- /dev/null +++ b/integration/fixtures/ocsp/ca.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIGV6FPfHeA42xfjVtpnyATG6tKCCu0QoY0OlBR/0xn2toAoGCCqGSM49 +AwEHoUQDQgAE+nwyg1QFtTGneNboVwMf6WBl+3VIimcaOQxWMtJtb0TOZFD+gxEd +Com8TvAlvAOz8lROKBG19kvS86iOeEdw9w== +-----END EC PRIVATE KEY----- diff --git a/integration/fixtures/ocsp/default.crt b/integration/fixtures/ocsp/default.crt new file mode 100644 index 000000000..85a651f66 --- /dev/null +++ b/integration/fixtures/ocsp/default.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjzCCATWgAwIBAgIIGDlFgswljYAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMH +VGVzdCBDQTAeFw0yNTA0MjQxMzUyMzhaFw0yNjA0MjQxMzUyMzhaMBgxFjAUBgNV +BAMTDWRlZmF1bHQubG9jYWwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQGABZ/ +zezTMQBwmmw3aifU0OkDQ4ZzxGG7dR93svJPgYnP7TpBVtPrxy0WgVZbbCHv0Srl +PlpO9rFkKf3D4E6Qo28wbTAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAf +BgNVHSMEGDAWgBQXRlWLK295lmDy+931a4Ha8XVNNjAsBggrBgEFBQcBAQQgMB4w +HAYIKwYBBQUHMAGGEG9jc3AuZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSAAwRQIh +AJMF7RkU0BtNZlHf//PPgpPfDJybnYMIoX1Ek4I8JZ+QAiBpxjzeFE9jwqcJnx5X +KnOJMbgfvJliZZgVSuXBbulzAA== +-----END CERTIFICATE----- diff --git a/integration/fixtures/ocsp/default.key b/integration/fixtures/ocsp/default.key new file mode 100644 index 000000000..96471e10d --- /dev/null +++ b/integration/fixtures/ocsp/default.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIO4UluA82wXVkaVH0m6oFGWyC8mzVcc7H9MI0ltXgkNuoAoGCCqGSM49 +AwEHoUQDQgAEBgAWf83s0zEAcJpsN2on1NDpA0OGc8Rhu3Ufd7LyT4GJz+06QVbT +68ctFoFWW2wh79Eq5T5aTvaxZCn9w+BOkA== +-----END EC PRIVATE KEY----- diff --git a/integration/fixtures/ocsp/gencert.go b/integration/fixtures/ocsp/gencert.go new file mode 100644 index 000000000..1c1277641 --- /dev/null +++ b/integration/fixtures/ocsp/gencert.go @@ -0,0 +1,100 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "time" +) + +func main() { + // generate CA + caKey, caCert := generateCA("Test CA") + saveKeyAndCert("integration/fixtures/ocsp/ca.key", "integration/fixtures/ocsp/ca.crt", caKey, caCert) + + // server certificate + serverKey, serverCert := generateCert("server.local", caKey, caCert) + saveKeyAndCert("integration/fixtures/ocsp/server.key", "integration/fixtures/ocsp/server.crt", serverKey, serverCert) + + // default certificate + defaultKey, defaultCert := generateCert("default.local", caKey, caCert) + saveKeyAndCert("integration/fixtures/ocsp/default.key", "integration/fixtures/ocsp/default.crt", defaultKey, defaultCert) +} + +func generateCA(commonName string) (*ecdsa.PrivateKey, *x509.Certificate) { + // generate a private key for the CA + caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + // create a self-signed CA certificate + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 ans + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + OCSPServer: []string{"ocsp.example.com"}, + } + + caCertDER, _ := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + caCert, _ := x509.ParseCertificate(caCertDER) + + return caKey, caCert +} + +func generateCert(commonName string, caKey *ecdsa.PrivateKey, caCert *x509.Certificate) (*ecdsa.PrivateKey, *x509.Certificate) { + // create a private key for the certificate + certKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + // create a certificate signed by the CA + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * 365 * 24 * time.Hour), // 1 an + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + OCSPServer: []string{"ocsp.example.com"}, + } + + certDER, _ := x509.CreateCertificate(rand.Reader, certTemplate, caCert, &certKey.PublicKey, caKey) + cert, _ := x509.ParseCertificate(certDER) + + return certKey, cert +} + +func saveKeyAndCert(keyFile, certFile string, key *ecdsa.PrivateKey, cert *x509.Certificate) { + // save the private key + keyOut, _ := os.Create(keyFile) + defer keyOut.Close() + + // Marshal the private key to ASN.1 DER format + privateKey, err := x509.MarshalECPrivateKey(key) + if err != nil { + panic(err) + } + + err = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKey}) + if err != nil { + panic(err) + } + + // save the certificate + certOut, _ := os.Create(certFile) + defer certOut.Close() + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + if err != nil { + panic(err) + } +} diff --git a/integration/fixtures/ocsp/server.crt b/integration/fixtures/ocsp/server.crt new file mode 100644 index 000000000..9e05c6e5c --- /dev/null +++ b/integration/fixtures/ocsp/server.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjjCCATSgAwIBAgIIGDlFgswgB3AwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMH +VGVzdCBDQTAeFw0yNTA0MjQxMzUyMzhaFw0yNjA0MjQxMzUyMzhaMBcxFTATBgNV +BAMTDHNlcnZlci5sb2NhbDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHpjZoVk +Qh15gTa26KMJfvzfVgGHGicUDg1UYppKAMY83rxSXqRHcVFAFRqWDTgCQRy6hPq+ +6p5OwBziC2X/SOejbzBtMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB8G +A1UdIwQYMBaAFBdGVYsrb3mWYPL73fVrgdrxdU02MCwGCCsGAQUFBwEBBCAwHjAc +BggrBgEFBQcwAYYQb2NzcC5leGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA +mp5LQixMUFh5h8yF1EtFsi4MKrO+dzD68TqIhq1rKjUCIEbB++M8qO4gtqjv8d06 +AzSLTEfgNCmM574JI46YAKVx +-----END CERTIFICATE----- diff --git a/integration/fixtures/ocsp/server.key b/integration/fixtures/ocsp/server.key new file mode 100644 index 000000000..3cd45737a --- /dev/null +++ b/integration/fixtures/ocsp/server.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFpVKKKxvw6cZe7hwRLHgXIsWiJYUQ66PKzO6iXINUH0oAoGCCqGSM49 +AwEHoUQDQgAEemNmhWRCHXmBNrboowl+/N9WAYcaJxQODVRimkoAxjzevFJepEdx +UUAVGpYNOAJBHLqE+r7qnk7AHOILZf9I5w== +-----END EC PRIVATE KEY----- diff --git a/integration/fixtures/ocsp/simple.toml b/integration/fixtures/ocsp/simple.toml new file mode 100644 index 000000000..13222b694 --- /dev/null +++ b/integration/fixtures/ocsp/simple.toml @@ -0,0 +1,27 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[providers.file] + filename = "{{ .SelfFilename }}" + +[ocsp.responderOverrides] + ocsp.example.com = "{{ .ResponderURL }}" + +[log] + level="debug" + +## dynamic configuration ## + +[[tls.certificates]] + certFile = "fixtures/ocsp/server.crt" + keyFile = "fixtures/ocsp/server.key" + +[tls.stores] + [tls.stores.default.defaultCertificate] + certFile = "fixtures/ocsp/default.crt" + keyFile = "fixtures/ocsp/default.key" diff --git a/integration/simple_test.go b/integration/simple_test.go index 791ee514c..675e21c3d 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -3,7 +3,9 @@ package integration import ( "bufio" "bytes" + "crypto" "crypto/rand" + "crypto/tls" "encoding/json" "fmt" "io" @@ -24,6 +26,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/traefik/traefik/v3/integration/try" "github.com/traefik/traefik/v3/pkg/config/dynamic" + "golang.org/x/crypto/ocsp" ) // SimpleSuite tests suite. @@ -1598,6 +1601,132 @@ func (s *SimpleSuite) TestMaxHeaderBytes() { } } +func (s *SimpleSuite) TestSimpleOCSP() { + defaultCert, err := tls.LoadX509KeyPair("fixtures/ocsp/default.crt", "fixtures/ocsp/default.key") + require.NoError(s.T(), err) + + serverCert, err := tls.LoadX509KeyPair("fixtures/ocsp/server.crt", "fixtures/ocsp/server.key") + require.NoError(s.T(), err) + + defaultOCSPResponseTmpl := ocsp.Response{ + SerialNumber: defaultCert.Leaf.SerialNumber, + Status: ocsp.Good, + ThisUpdate: defaultCert.Leaf.NotBefore, + NextUpdate: defaultCert.Leaf.NotAfter, + } + defaultOCSPResponse, err := ocsp.CreateResponse(defaultCert.Leaf, defaultCert.Leaf, defaultOCSPResponseTmpl, defaultCert.PrivateKey.(crypto.Signer)) + require.NoError(s.T(), err) + + serverOCSPResponseTmpl := ocsp.Response{ + SerialNumber: serverCert.Leaf.SerialNumber, + Status: ocsp.Good, + ThisUpdate: serverCert.Leaf.NotBefore, + NextUpdate: serverCert.Leaf.NotAfter, + } + serverOCSPResponse, err := ocsp.CreateResponse(serverCert.Leaf, serverCert.Leaf, serverOCSPResponseTmpl, serverCert.PrivateKey.(crypto.Signer)) + require.NoError(s.T(), err) + + responderCalled := make(chan struct{}) + responder := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + ct := req.Header.Get("Content-Type") + assert.Equal(s.T(), "application/ocsp-request", ct) + + reqBytes, err := io.ReadAll(req.Body) + require.NoError(s.T(), err) + + ocspReq, err := ocsp.ParseRequest(reqBytes) + require.NoError(s.T(), err) + + var ocspResponse []byte + switch ocspReq.SerialNumber.String() { + case defaultCert.Leaf.SerialNumber.String(): + ocspResponse = defaultOCSPResponse + case serverCert.Leaf.SerialNumber.String(): + ocspResponse = serverOCSPResponse + default: + s.T().Fatalf("Unexpected OCSP request for serial number: %s", ocspReq.SerialNumber) + } + + rw.Header().Set("Content-Type", "application/ocsp-response") + + _, err = rw.Write(ocspResponse) + require.NoError(s.T(), err) + + responderCalled <- struct{}{} + })) + s.T().Cleanup(responder.Close) + + file := s.adaptFile("fixtures/ocsp/simple.toml", struct { + ResponderURL string + }{responder.URL}) + + s.traefikCmd(withConfigFile(file)) + + select { + case <-responderCalled: + case <-time.After(5 * time.Second): + s.T().Fatal("OCSP responder was not called") + } + + select { + case <-responderCalled: + case <-time.After(5 * time.Second): + s.T().Fatal("OCSP responder was not called") + } + + // Check that the response is stapled. + + // Create a TLS client configuration that checks for OCSP stapling for the default cert. + var verifyCallCount int + clientConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: "unknown", + VerifyConnection: func(state tls.ConnectionState) error { + s.T().Helper() + + verifyCallCount++ + assert.Equal(s.T(), "default.local", state.PeerCertificates[0].Subject.CommonName) + assert.Equal(s.T(), defaultOCSPResponse, state.OCSPResponse) + return nil + }, + } + + // Connect to the server and verify OCSP stapling. + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", "127.0.0.1:8000", clientConfig) + require.NoError(s.T(), err) + + s.T().Cleanup(func() { + _ = conn.Close() + }) + + assert.Equal(s.T(), 1, verifyCallCount) + + // Create a TLS client configuration that checks for OCSP stapling for a cert in the store. + verifyCallCount = 0 + clientConfig = &tls.Config{ + InsecureSkipVerify: true, + ServerName: "server.local", + VerifyConnection: func(state tls.ConnectionState) error { + s.T().Helper() + + verifyCallCount++ + assert.Equal(s.T(), "server.local", state.PeerCertificates[0].Subject.CommonName) + assert.Equal(s.T(), serverOCSPResponse, state.OCSPResponse) + return nil + }, + } + + // Connect to the server and verify OCSP stapling. + conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", "127.0.0.1:8000", clientConfig) + require.NoError(s.T(), err) + + s.T().Cleanup(func() { + _ = conn.Close() + }) + + assert.Equal(s.T(), 1, verifyCallCount) +} + func (s *SimpleSuite) TestSanitizePath() { s.createComposeProject("base") diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index af60efbb8..907d56ba9 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -28,6 +28,7 @@ import ( "github.com/traefik/traefik/v3/pkg/provider/kv/zk" "github.com/traefik/traefik/v3/pkg/provider/nomad" "github.com/traefik/traefik/v3/pkg/provider/rest" + "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/types" ) @@ -80,6 +81,8 @@ type Configuration struct { Core *Core `description:"Core controls." json:"core,omitempty" toml:"core,omitempty" yaml:"core,omitempty" export:"true"` Spiffe *SpiffeClientConfig `description:"SPIFFE integration configuration." json:"spiffe,omitempty" toml:"spiffe,omitempty" yaml:"spiffe,omitempty" export:"true"` + + OCSP *tls.OCSPConfig `description:"OCSP configuration." json:"ocsp,omitempty" toml:"ocsp,omitempty" yaml:"ocsp,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` } // Core configures Traefik core behavior. @@ -424,6 +427,14 @@ func (c *Configuration) ValidateConfiguration() error { return errors.New("API basePath must be a valid absolute path") } + if c.OCSP != nil { + for responderURL, url := range c.OCSP.ResponderOverrides { + if url == "" { + return fmt.Errorf("OCSP responder override value for %s cannot be empty", responderURL) + } + } + } + return nil } diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go index 9191c78d3..50811be7c 100644 --- a/pkg/server/router/router_test.go +++ b/pkg/server/router/router_test.go @@ -325,7 +325,7 @@ func TestRouterManager_Get(t *testing.T) { serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{}) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) @@ -712,7 +712,7 @@ func TestRuntimeConfiguration(t *testing.T) { serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, proxyBuilderMock{}) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) tlsManager.UpdateConfigs(t.Context(), nil, test.tlsOptions, nil) parser, err := httpmuxer.NewSyntaxParser() @@ -794,7 +794,7 @@ func TestProviderOnMiddlewares(t *testing.T) { serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) @@ -873,7 +873,7 @@ func BenchmarkRouterServe(b *testing.B) { serviceManager := service.NewManager(rtConf.Services, nil, nil, staticTransportManager{res}, nil) middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) parser, err := httpmuxer.NewSyntaxParser() require.NoError(b, err) diff --git a/pkg/server/router/tcp/manager_test.go b/pkg/server/router/tcp/manager_test.go index 02d49bde9..32b3218e0 100644 --- a/pkg/server/router/tcp/manager_test.go +++ b/pkg/server/router/tcp/manager_test.go @@ -347,7 +347,7 @@ func TestRuntimeConfiguration(t *testing.T) { dialerManager := tcp2.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) serviceManager := tcp.NewManager(conf, dialerManager) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) tlsManager.UpdateConfigs( t.Context(), map[string]traefiktls.Store{}, @@ -659,7 +659,7 @@ func TestDomainFronting(t *testing.T) { serviceManager := tcp.NewManager(conf, tcp2.NewDialerManager(nil)) - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) tlsManager.UpdateConfigs(t.Context(), map[string]traefiktls.Store{}, test.tlsOptions, []*traefiktls.CertAndStores{}) httpsHandler := map[string]http.Handler{ diff --git a/pkg/server/router/tcp/router_test.go b/pkg/server/router/tcp/router_test.go index b4e4e3edb..1c2875776 100644 --- a/pkg/server/router/tcp/router_test.go +++ b/pkg/server/router/tcp/router_test.go @@ -172,7 +172,7 @@ func Test_Routing(t *testing.T) { require.NoError(t, err) // Creates the tlsManager and defines the TLS 1.0 and 1.2 TLSOptions. - tlsManager := traefiktls.NewManager() + tlsManager := traefiktls.NewManager(nil) tlsManager.UpdateConfigs( t.Context(), map[string]traefiktls.Store{ diff --git a/pkg/server/routerfactory_test.go b/pkg/server/routerfactory_test.go index 648b24e8f..8a536890f 100644 --- a/pkg/server/routerfactory_test.go +++ b/pkg/server/routerfactory_test.go @@ -55,7 +55,7 @@ func TestReuseService(t *testing.T) { transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) - tlsManager := tls.NewManager() + tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) @@ -193,7 +193,7 @@ func TestServerResponseEmptyBackend(t *testing.T) { transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) - tlsManager := tls.NewManager() + tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) @@ -239,7 +239,7 @@ func TestInternalServices(t *testing.T) { transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) - tlsManager := tls.NewManager() + tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) diff --git a/pkg/tls/certificate.go b/pkg/tls/certificate.go index ea7f895ae..f99796783 100644 --- a/pkg/tls/certificate.go +++ b/pkg/tls/certificate.go @@ -7,7 +7,6 @@ import ( "fmt" "net/url" "os" - "sort" "strings" "github.com/rs/zerolog/log" @@ -76,68 +75,6 @@ type Certificate struct { KeyFile types.FileOrContent `json:"keyFile,omitempty" toml:"keyFile,omitempty" yaml:"keyFile,omitempty" loggable:"false"` } -// AppendCertificate appends a Certificate to a certificates map keyed by store name. -func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certificate, storeName string) error { - certContent, err := c.CertFile.Read() - if err != nil { - return fmt.Errorf("unable to read CertFile : %w", err) - } - - keyContent, err := c.KeyFile.Read() - if err != nil { - return fmt.Errorf("unable to read KeyFile : %w", err) - } - tlsCert, err := tls.X509KeyPair(certContent, keyContent) - if err != nil { - return fmt.Errorf("unable to generate TLS certificate : %w", err) - } - - parsedCert, _ := x509.ParseCertificate(tlsCert.Certificate[0]) - - var SANs []string - if parsedCert.Subject.CommonName != "" { - SANs = append(SANs, strings.ToLower(parsedCert.Subject.CommonName)) - } - if parsedCert.DNSNames != nil { - for _, dnsName := range parsedCert.DNSNames { - if dnsName != parsedCert.Subject.CommonName { - SANs = append(SANs, strings.ToLower(dnsName)) - } - } - } - if parsedCert.IPAddresses != nil { - for _, ip := range parsedCert.IPAddresses { - if ip.String() != parsedCert.Subject.CommonName { - SANs = append(SANs, strings.ToLower(ip.String())) - } - } - } - - // Guarantees the order to produce a unique cert key. - sort.Strings(SANs) - certKey := strings.Join(SANs, ",") - - certExists := false - if certs[storeName] == nil { - certs[storeName] = make(map[string]*tls.Certificate) - } else { - for domains := range certs[storeName] { - if domains == certKey { - certExists = true - break - } - } - } - if certExists { - log.Debug().Msgf("Skipping addition of certificate for domain(s) %q, to TLS Store %s, as it already exists for this store.", certKey, storeName) - } else { - log.Debug().Msgf("Adding certificate for domain(s) %s", certKey) - certs[storeName][certKey] = &tlsCert - } - - return err -} - // GetCertificate returns a tls.Certificate matching the configured CertFile and KeyFile. func (c *Certificate) GetCertificate() (tls.Certificate, error) { certContent, err := c.CertFile.Read() @@ -169,24 +106,6 @@ func (c *Certificate) GetCertificateFromBytes() (tls.Certificate, error) { return cert, nil } -// Set is the method to set the flag value, part of the flag.Value interface. -// Set's argument is a string to be parsed to set the flag. -// It's a comma-separated list, so we split it. -func (c *Certificates) Set(value string) error { - certificates := strings.Split(value, ";") - for _, certificate := range certificates { - files := strings.Split(certificate, ",") - if len(files) != 2 { - return fmt.Errorf("bad certificates format: %s", value) - } - *c = append(*c, Certificate{ - CertFile: types.FileOrContent(files[0]), - KeyFile: types.FileOrContent(files[1]), - }) - } - return nil -} - // GetTruncatedCertificateName truncates the certificate name. func (c *Certificate) GetTruncatedCertificateName() string { certName := c.CertFile.String() diff --git a/pkg/tls/certificate_store.go b/pkg/tls/certificate_store.go index 2ead96ccf..57979f45d 100644 --- a/pkg/tls/certificate_store.go +++ b/pkg/tls/certificate_store.go @@ -2,7 +2,7 @@ package tls import ( "crypto/tls" - "crypto/x509" + "fmt" "net" "sort" "strings" @@ -13,57 +13,40 @@ import ( "github.com/traefik/traefik/v3/pkg/safe" ) +// CertificateData holds runtime data for runtime TLS certificate handling. +type CertificateData struct { + Hash string + Certificate *tls.Certificate +} + // CertificateStore store for dynamic certificates. type CertificateStore struct { DynamicCerts *safe.Safe - DefaultCertificate *tls.Certificate + DefaultCertificate *CertificateData CertCache *cache.Cache + + ocspStapler *ocspStapler } // NewCertificateStore create a store for dynamic certificates. -func NewCertificateStore() *CertificateStore { - s := &safe.Safe{} - s.Set(make(map[string]*tls.Certificate)) +func NewCertificateStore(ocspStapler *ocspStapler) *CertificateStore { + var dynamicCerts safe.Safe + dynamicCerts.Set(make(map[string]*CertificateData)) return &CertificateStore{ - DynamicCerts: s, + DynamicCerts: &dynamicCerts, CertCache: cache.New(1*time.Hour, 10*time.Minute), + ocspStapler: ocspStapler, } } -func (c *CertificateStore) getDefaultCertificateDomains() []string { - var allCerts []string - - if c.DefaultCertificate == nil { - return allCerts - } - - x509Cert, err := x509.ParseCertificate(c.DefaultCertificate.Certificate[0]) - if err != nil { - log.Error().Err(err).Msg("Could not parse default certificate") - return allCerts - } - - if len(x509Cert.Subject.CommonName) > 0 { - allCerts = append(allCerts, x509Cert.Subject.CommonName) - } - - allCerts = append(allCerts, x509Cert.DNSNames...) - - for _, ipSan := range x509Cert.IPAddresses { - allCerts = append(allCerts, ipSan.String()) - } - - return allCerts -} - // GetAllDomains return a slice with all the certificate domain. func (c *CertificateStore) GetAllDomains() []string { allDomains := c.getDefaultCertificateDomains() // Get dynamic certificates if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { - for domain := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { + for domain := range c.DynamicCerts.Get().(map[string]*CertificateData) { allDomains = append(allDomains, domain) } } @@ -71,6 +54,23 @@ func (c *CertificateStore) GetAllDomains() []string { return allDomains } +// GetDefaultCertificate returns the default certificate. +func (c *CertificateStore) GetDefaultCertificate() *tls.Certificate { + if c == nil { + return nil + } + + if c.ocspStapler != nil && c.DefaultCertificate.Hash != "" { + if staple, ok := c.ocspStapler.GetStaple(c.DefaultCertificate.Hash); ok { + // We are updating the OCSPStaple of the certificate without any synchronization + // as this should not cause any issue. + c.DefaultCertificate.Certificate.OCSPStaple = staple + } + } + + return c.DefaultCertificate.Certificate +} + // GetBestCertificate returns the best match certificate, and caches the response. func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) *tls.Certificate { if c == nil { @@ -87,12 +87,21 @@ func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) } if cert, ok := c.CertCache.Get(serverName); ok { - return cert.(*tls.Certificate) + certificateData := cert.(*CertificateData) + if c.ocspStapler != nil && certificateData.Hash != "" { + if staple, ok := c.ocspStapler.GetStaple(certificateData.Hash); ok { + // We are updating the OCSPStaple of the certificate without any synchronization + // as this should not cause any issue. + certificateData.Certificate.OCSPStaple = staple + } + } + + return certificateData.Certificate } - matchedCerts := map[string]*tls.Certificate{} + matchedCerts := map[string]*CertificateData{} if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { - for domains, cert := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { + for domains, cert := range c.DynamicCerts.Get().(map[string]*CertificateData) { for _, certDomain := range strings.Split(domains, ",") { if matchDomain(serverName, certDomain) { matchedCerts[certDomain] = cert @@ -110,15 +119,25 @@ func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) sort.Strings(keys) // cache best match - c.CertCache.SetDefault(serverName, matchedCerts[keys[len(keys)-1]]) - return matchedCerts[keys[len(keys)-1]] + certificateData := matchedCerts[keys[len(keys)-1]] + c.CertCache.SetDefault(serverName, certificateData) + + if c.ocspStapler != nil && certificateData.Hash != "" { + if staple, ok := c.ocspStapler.GetStaple(certificateData.Hash); ok { + // We are updating the OCSPStaple of the certificate without any synchronization + // as this should not cause any issue. + certificateData.Certificate.OCSPStaple = staple + } + } + + return certificateData.Certificate } return nil } // GetCertificate returns the first certificate matching all the given domains. -func (c *CertificateStore) GetCertificate(domains []string) *tls.Certificate { +func (c *CertificateStore) GetCertificate(domains []string) *CertificateData { if c == nil { return nil } @@ -127,11 +146,11 @@ func (c *CertificateStore) GetCertificate(domains []string) *tls.Certificate { domainsKey := strings.Join(domains, ",") if cert, ok := c.CertCache.Get(domainsKey); ok { - return cert.(*tls.Certificate) + return cert.(*CertificateData) } if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { - for certDomains, cert := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { + for certDomains, cert := range c.DynamicCerts.Get().(map[string]*CertificateData) { if domainsKey == certDomains { c.CertCache.SetDefault(domainsKey, cert) return cert @@ -163,6 +182,91 @@ func (c *CertificateStore) ResetCache() { } } +func (c *CertificateStore) getDefaultCertificateDomains() []string { + if c.DefaultCertificate == nil { + return nil + } + + defaultCert := c.DefaultCertificate.Certificate.Leaf + + var allCerts []string + if len(defaultCert.Subject.CommonName) > 0 { + allCerts = append(allCerts, defaultCert.Subject.CommonName) + } + + allCerts = append(allCerts, defaultCert.DNSNames...) + + for _, ipSan := range defaultCert.IPAddresses { + allCerts = append(allCerts, ipSan.String()) + } + + return allCerts +} + +// appendCertificate appends a Certificate to a certificates map keyed by store name. +func appendCertificate(certs map[string]map[string]*CertificateData, subjectAltNames []string, storeName string, cert *CertificateData) { + // Guarantees the order to produce a unique cert key. + sort.Strings(subjectAltNames) + certKey := strings.Join(subjectAltNames, ",") + + certExists := false + if certs[storeName] == nil { + certs[storeName] = make(map[string]*CertificateData) + } else { + for domains := range certs[storeName] { + if domains == certKey { + certExists = true + break + } + } + } + if certExists { + log.Debug().Msgf("Skipping addition of certificate for domain(s) %q, to TLS Store %s, as it already exists for this store.", certKey, storeName) + } else { + log.Debug().Msgf("Adding certificate for domain(s) %s", certKey) + + certs[storeName][certKey] = cert + } +} + +func parseCertificate(cert *Certificate) (tls.Certificate, []string, error) { + certContent, err := cert.CertFile.Read() + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("unable to read CertFile: %w", err) + } + + keyContent, err := cert.KeyFile.Read() + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("unable to read KeyFile: %w", err) + } + + tlsCert, err := tls.X509KeyPair(certContent, keyContent) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("unable to generate TLS certificate: %w", err) + } + + var SANs []string + if tlsCert.Leaf.Subject.CommonName != "" { + SANs = append(SANs, strings.ToLower(tlsCert.Leaf.Subject.CommonName)) + } + if tlsCert.Leaf.DNSNames != nil { + for _, dnsName := range tlsCert.Leaf.DNSNames { + if dnsName != tlsCert.Leaf.Subject.CommonName { + SANs = append(SANs, strings.ToLower(dnsName)) + } + } + } + if tlsCert.Leaf.IPAddresses != nil { + for _, ip := range tlsCert.Leaf.IPAddresses { + if ip.String() != tlsCert.Leaf.Subject.CommonName { + SANs = append(SANs, strings.ToLower(ip.String())) + } + } + } + + return tlsCert, SANs, err +} + // matchDomain returns whether the server name matches the cert domain. // The server name, from TLS SNI, must not have trailing dots (https://datatracker.ietf.org/doc/html/rfc6066#section-3). // This is enforced by https://github.com/golang/go/blob/d3d7998756c33f69706488cade1cd2b9b10a4c7f/src/crypto/tls/handshake_messages.go#L423-L427. diff --git a/pkg/tls/certificate_store_test.go b/pkg/tls/certificate_store_test.go index cbc668bd6..ef4fd3885 100644 --- a/pkg/tls/certificate_store_test.go +++ b/pkg/tls/certificate_store_test.go @@ -58,12 +58,12 @@ func TestGetBestCertificate(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - dynamicMap := map[string]*tls.Certificate{} + dynamicMap := map[string]*CertificateData{} if test.dynamicCert != "" { cert, err := loadTestCert(test.dynamicCert, test.uppercase) require.NoError(t, err) - dynamicMap[strings.ToLower(test.dynamicCert)] = cert + dynamicMap[strings.ToLower(test.dynamicCert)] = &CertificateData{Certificate: cert} } store := &CertificateStore{ diff --git a/pkg/tls/ocsp.go b/pkg/tls/ocsp.go new file mode 100644 index 000000000..1517458ae --- /dev/null +++ b/pkg/tls/ocsp.go @@ -0,0 +1,206 @@ +package tls + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/patrickmn/go-cache" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/ocsp" +) + +const defaultCacheDuration = 24 * time.Hour + +type ocspEntry struct { + leaf *x509.Certificate + issuer *x509.Certificate + responders []string + nextUpdate time.Time + staple []byte +} + +// ocspStapler retrieves staples from OCSP responders and store them in an in-memory cache. +// It also updates the staples on a regular basis and before they expire. +type ocspStapler struct { + client *http.Client + cache cache.Cache + forceStapleUpdates chan struct{} + responderOverrides map[string]string +} + +// newOCSPStapler creates a new ocspStapler cache. +func newOCSPStapler(responderOverrides map[string]string) *ocspStapler { + return &ocspStapler{ + client: &http.Client{Timeout: 10 * time.Second}, + cache: *cache.New(defaultCacheDuration, 5*time.Minute), + forceStapleUpdates: make(chan struct{}, 1), + responderOverrides: responderOverrides, + } +} + +// Run updates the OCSP staples every hours. +func (o *ocspStapler) Run(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + select { + case <-ctx.Done(): + return + + case <-o.forceStapleUpdates: + o.updateStaples(ctx) + + case <-ticker.C: + o.updateStaples(ctx) + } +} + +// ForceStapleUpdates triggers staple updates in the background instead of waiting for the Run routine to update them. +func (o *ocspStapler) ForceStapleUpdates() { + select { + case o.forceStapleUpdates <- struct{}{}: + default: + } +} + +// GetStaple retrieves the OCSP staple for the corresponding to the given key (public certificate hash). +func (o *ocspStapler) GetStaple(key string) ([]byte, bool) { + if item, ok := o.cache.Get(key); ok && item != nil { + if entry, ok := item.(*ocspEntry); ok { + return entry.staple, true + } + } + return nil, false +} + +// Upsert creates a new entry for the given certificate. +// The ocspStapler will then be responsible from retrieving and updating the corresponding OCSP obtainStaple. +func (o *ocspStapler) Upsert(key string, leaf, issuer *x509.Certificate) error { + if len(leaf.OCSPServer) == 0 { + return errors.New("leaf certificate does not contain an OCSP server") + } + + if item, ok := o.cache.Get(key); ok { + o.cache.Set(key, item, cache.NoExpiration) + return nil + } + + var responders []string + for _, url := range leaf.OCSPServer { + if len(o.responderOverrides) > 0 { + if newURL, ok := o.responderOverrides[url]; ok { + url = newURL + } + } + responders = append(responders, url) + } + + o.cache.Set(key, &ocspEntry{ + leaf: leaf, + issuer: issuer, + responders: responders, + }, cache.NoExpiration) + + return nil +} + +// ResetTTL resets the expiration time for all items having no expiration. +// This allows setting a TTL for certificates that do not exist anymore in the dynamic configuration. +// For certificates that are still provided by the dynamic configuration, +// their expiration time will be unset when calling the Upsert method. +func (o *ocspStapler) ResetTTL() { + for key, item := range o.cache.Items() { + if item.Expiration > 0 { + continue + } + + o.cache.Set(key, item.Object, defaultCacheDuration) + } +} + +func (o *ocspStapler) updateStaples(ctx context.Context) { + for _, item := range o.cache.Items() { + select { + case <-ctx.Done(): + return + default: + } + + entry := item.Object.(*ocspEntry) + + if entry.staple != nil && time.Now().Before(entry.nextUpdate) { + continue + } + + if err := o.updateStaple(ctx, entry); err != nil { + log.Error().Err(err).Msgf("Unable to retieve OCSP staple for: %s", entry.leaf.Subject.CommonName) + continue + } + } +} + +// obtainStaple obtains the OCSP stable for the given leaf certificate. +func (o *ocspStapler) updateStaple(ctx context.Context, entry *ocspEntry) error { + ocspReq, err := ocsp.CreateRequest(entry.leaf, entry.issuer, nil) + if err != nil { + return fmt.Errorf("creating OCSP request: %w", err) + } + + for _, responder := range entry.responders { + logger := log.With().Str("responder", responder).Logger() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, responder, bytes.NewReader(ocspReq)) + if err != nil { + return fmt.Errorf("creating OCSP request: %w", err) + } + + req.Header.Set("Content-Type", "application/ocsp-request") + + res, err := o.client.Do(req) + if err != nil && ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + logger.Debug().Err(err).Msg("Unable to obtain OCSP response") + continue + } + defer res.Body.Close() + + if res.StatusCode/100 != 2 { + logger.Debug().Msgf("Unable to obtain OCSP response due to status code: %d", res.StatusCode) + continue + } + + ocspResBytes, err := io.ReadAll(res.Body) + if err != nil { + logger.Debug().Err(err).Msg("Unable to read OCSP response bytes") + continue + } + + ocspRes, err := ocsp.ParseResponseForCert(ocspResBytes, entry.leaf, entry.issuer) + if err != nil { + logger.Debug().Err(err).Msg("Unable to parse OCSP response") + continue + } + + entry.staple = ocspResBytes + + // As per RFC 6960, the nextUpdate field is optional. + if ocspRes.NextUpdate.IsZero() { + // NextUpdate is not set, the staple should be updated on the next update. + entry.nextUpdate = time.Now() + } else { + entry.nextUpdate = ocspRes.ThisUpdate.Add(ocspRes.NextUpdate.Sub(ocspRes.ThisUpdate) / 2) + } + + return nil + } + + return errors.New("no OCSP staple obtained from any responders") +} diff --git a/pkg/tls/ocsp_test.go b/pkg/tls/ocsp_test.go new file mode 100644 index 000000000..0cbb69a89 --- /dev/null +++ b/pkg/tls/ocsp_test.go @@ -0,0 +1,485 @@ +package tls + +import ( + "crypto" + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/patrickmn/go-cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ocsp" +) + +const certWithOCSPServer = `-----BEGIN CERTIFICATE----- +MIIBgjCCASegAwIBAgICIAAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMHVGVzdCBD +QTAeFw0yMzAxMDExMjAwMDBaFw0yMzAyMDExMjAwMDBaMCAxHjAcBgNVBAMTFU9D +U1AgVGVzdCBDZXJ0aWZpY2F0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIoe +I/bjo34qony8LdRJD+Jhuk8/S8YHXRHl6rH9t5VFCFtX8lIPN/Ll1zCrQ2KB3Wlb +fxSgiQyLrCpZyrdhVPSjXzBdMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAU+Eo3 +5sST4LRrwS4dueIdGBZ5d7IwLAYIKwYBBQUHAQEEIDAeMBwGCCsGAQUFBzABhhBv +Y3NwLmV4YW1wbGUuY29tMAoGCCqGSM49BAMCA0kAMEYCIQDg94xY/+/VepESdvTT +ykCwiWOS2aCpjyryrKpwMKkR0AIhAPc/+ZEz4W10OENxC1t+NUTvS8JbEGOwulkZ +z9yfaLuD +-----END CERTIFICATE-----` + +const certWithoutOCSPServer = `-----BEGIN CERTIFICATE----- +MIIBUzCB+aADAgECAgIgADAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdUZXN0IENB +MB4XDTIzMDEwMTEyMDAwMFoXDTIzMDIwMTEyMDAwMFowIDEeMBwGA1UEAxMVT0NT +UCBUZXN0IENlcnRpZmljYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEih4j +9uOjfiqifLwt1EkP4mG6Tz9LxgddEeXqsf23lUUIW1fyUg838uXXMKtDYoHdaVt/ +FKCJDIusKlnKt2FU9KMxMC8wDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBT4Sjfm +xJPgtGvBLh254h0YFnl3sjAKBggqhkjOPQQDAgNJADBGAiEA3rWetLGblfSuNZKf +5CpZxhj3A0BjEocEh+2P+nAgIdUCIQDIgptabR1qTLQaF2u0hJsEX2IKuIUvYWH3 +6Lb92+zIHg== +-----END CERTIFICATE-----` + +// certKey is the private key for both certWithOCSPServer and certWithoutOCSPServer. +const certKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINnVcgrSNh4HlThWlZpegq14M8G/p9NVDtdVjZrseUGLoAoGCCqGSM49 +AwEHoUQDQgAEih4j9uOjfiqifLwt1EkP4mG6Tz9LxgddEeXqsf23lUUIW1fyUg83 +8uXXMKtDYoHdaVt/FKCJDIusKlnKt2FU9A== +-----END EC PRIVATE KEY-----` + +// caCert is the issuing certificate for certWithOCSPServer and certWithoutOCSPServer. +const caCert = `-----BEGIN CERTIFICATE----- +MIIBazCCARGgAwIBAgICEAAwCgYIKoZIzj0EAwIwEjEQMA4GA1UEAxMHVGVzdCBD +QTAeFw0yMzAxMDExMjAwMDBaFw0yMzAyMDExMjAwMDBaMBIxEDAOBgNVBAMTB1Rl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASdKexSor/aeazDM57UHhAX +rCkJxUeF2BWf0lZYCRxc3f0GdrEsVvjJW8+/E06eAzDCGSdM/08Nvun1nb6AmAlt +o1cwVTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwkwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQU+Eo35sST4LRrwS4dueIdGBZ5d7IwCgYIKoZI +zj0EAwIDSAAwRQIgGbA39+kETTB/YMLBFoC2fpZe1cDWfFB7TUdfINUqdH4CIQCR +ByUFC8A+hRNkK5YNH78bgjnKk/88zUQF5ONy4oPGdQ== +-----END CERTIFICATE-----` + +const caKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDJ59ptjq3MzILH4zn5IKoH1sYn+zrUeq2kD8+DD2x+OoAoGCCqGSM49 +AwEHoUQDQgAEnSnsUqK/2nmswzOe1B4QF6wpCcVHhdgVn9JWWAkcXN39BnaxLFb4 +yVvPvxNOngMwwhknTP9PDb7p9Z2+gJgJbQ== +-----END EC PRIVATE KEY-----` + +func TestOCSPStapler_Upsert(t *testing.T) { + ocspStapler := newOCSPStapler(nil) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + // Upsert a certificate without an OCSP server should raise an error. + leafCertWithoutOCSPServer, err := tls.X509KeyPair([]byte(certWithoutOCSPServer), []byte(certKey)) + require.NoError(t, err) + + err = ocspStapler.Upsert("foo", leafCertWithoutOCSPServer.Leaf, issuerCert.Leaf) + require.Error(t, err) + + // Upsert a certificate with an OCSP server. + err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf) + require.NoError(t, err) + + i, ok := ocspStapler.cache.Get("foo") + require.True(t, ok) + + e, ok := i.(*ocspEntry) + require.True(t, ok) + + assert.Equal(t, leafCert.Leaf, e.leaf) + assert.Equal(t, issuerCert.Leaf, e.issuer) + assert.Nil(t, e.staple) + assert.Equal(t, []string{"ocsp.example.com"}, e.responders) + assert.Equal(t, int64(0), ocspStapler.cache.Items()["foo"].Expiration) + + // Upsert an existing entry to make sure that the existing staple is preserved. + e.staple = []byte("foo") + e.nextUpdate = time.Now() + e.responders = []string{"foo.com"} + + err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf) + require.NoError(t, err) + + i, ok = ocspStapler.cache.Get("foo") + require.True(t, ok) + + e, ok = i.(*ocspEntry) + require.True(t, ok) + + assert.Equal(t, leafCert.Leaf, e.leaf) + assert.Equal(t, issuerCert.Leaf, e.issuer) + assert.Equal(t, []byte("foo"), e.staple) + assert.NotZero(t, e.nextUpdate) + assert.Equal(t, []string{"foo.com"}, e.responders) + assert.Equal(t, int64(0), ocspStapler.cache.Items()["foo"].Expiration) +} + +func TestOCSPStapler_Upsert_withResponderOverrides(t *testing.T) { + ocspStapler := newOCSPStapler(map[string]string{ + "ocsp.example.com": "foo.com", + }) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + err = ocspStapler.Upsert("foo", leafCert.Leaf, issuerCert.Leaf) + require.NoError(t, err) + + i, ok := ocspStapler.cache.Get("foo") + require.True(t, ok) + + e, ok := i.(*ocspEntry) + require.True(t, ok) + + assert.Equal(t, leafCert.Leaf, e.leaf) + assert.Equal(t, issuerCert.Leaf, e.issuer) + assert.Nil(t, e.staple) + assert.Equal(t, []string{"foo.com"}, e.responders) +} + +func TestOCSPStapler_ResetTTL(t *testing.T) { + ocspStapler := newOCSPStapler(nil) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + ocspStapler.cache.Set("foo", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{"foo.com"}, + nextUpdate: time.Now(), + staple: []byte("foo"), + }, cache.NoExpiration) + + ocspStapler.cache.Set("bar", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{"bar.com"}, + nextUpdate: time.Now(), + staple: []byte("bar"), + }, time.Hour) + + wantBarExpiration := ocspStapler.cache.Items()["bar"].Expiration + + ocspStapler.ResetTTL() + + item, ok := ocspStapler.cache.Items()["foo"] + require.True(t, ok) + + e, ok := item.Object.(*ocspEntry) + require.True(t, ok) + + assert.Positive(t, item.Expiration) + assert.Equal(t, leafCert.Leaf, e.leaf) + assert.Equal(t, issuerCert.Leaf, e.issuer) + assert.Equal(t, []byte("foo"), e.staple) + assert.NotZero(t, e.nextUpdate) + assert.Equal(t, []string{"foo.com"}, e.responders) + + item, ok = ocspStapler.cache.Items()["bar"] + require.True(t, ok) + + e, ok = item.Object.(*ocspEntry) + require.True(t, ok) + + assert.Equal(t, wantBarExpiration, item.Expiration) + assert.Equal(t, leafCert.Leaf, e.leaf) + assert.Equal(t, issuerCert.Leaf, e.issuer) + assert.Equal(t, []byte("bar"), e.staple) + assert.NotZero(t, e.nextUpdate) + assert.Equal(t, []string{"bar.com"}, e.responders) +} + +func TestOCSPStapler_GetStaple(t *testing.T) { + ocspStapler := newOCSPStapler(nil) + + // Get an un-existing staple. + staple, exists := ocspStapler.GetStaple("foo") + + assert.False(t, exists) + assert.Nil(t, staple) + + // Get an existing staple. + ocspStapler.cache.Set("foo", &ocspEntry{staple: []byte("foo")}, cache.NoExpiration) + + staple, exists = ocspStapler.GetStaple("foo") + + assert.True(t, exists) + assert.Equal(t, []byte("foo"), staple) +} + +func TestOCSPStapler_updateStaple(t *testing.T) { + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + thisUpdate, err := time.Parse("2006-01-02", "2025-01-01") + require.NoError(t, err) + nextUpdate, err := time.Parse("2006-01-02", "2025-01-02") + require.NoError(t, err) + stapleUpdate := thisUpdate.Add(nextUpdate.Sub(thisUpdate) / 2) + + ocspResponseTmpl := ocsp.Response{ + SerialNumber: leafCert.Leaf.SerialNumber, + TBSResponseData: []byte("foo"), + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + } + + ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer)) + require.NoError(t, err) + + handler := func(rw http.ResponseWriter, req *http.Request) { + ct := req.Header.Get("Content-Type") + assert.Equal(t, "application/ocsp-request", ct) + + reqBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = ocsp.ParseRequest(reqBytes) + require.NoError(t, err) + + rw.Header().Set("Content-Type", "application/ocsp-response") + + _, err = rw.Write(ocspResponse) + require.NoError(t, err) + } + + responder := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(responder.Close) + + responderStatusNotOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(responderStatusNotOK.Close) + + testCases := []struct { + desc string + entry *ocspEntry + expectError bool + }{ + { + desc: "no responder", + entry: &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + }, + expectError: true, + }, + { + desc: "wrong responder", + entry: &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{"http://foo.bar"}, + }, + expectError: true, + }, + { + desc: "not ok status responder", + entry: &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responderStatusNotOK.URL}, + }, + expectError: true, + }, + { + desc: "one wrong responder, one ok", + entry: &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{"http://foo.bar", responder.URL}, + }, + }, + { + desc: "ok responder", + entry: &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responder.URL}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + ocspStapler := newOCSPStapler(nil) + ocspStapler.client = &http.Client{Timeout: time.Second} + + err = ocspStapler.updateStaple(t.Context(), test.entry) + if test.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + assert.Equal(t, ocspResponse, test.entry.staple) + assert.Equal(t, stapleUpdate.UTC(), test.entry.nextUpdate) + }) + } +} + +func TestOCSPStapler_updateStaple_withoutNextUpdate(t *testing.T) { + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + thisUpdate, err := time.Parse("2006-01-02", "2025-01-01") + require.NoError(t, err) + + ocspResponseTmpl := ocsp.Response{ + SerialNumber: leafCert.Leaf.SerialNumber, + TBSResponseData: []byte("foo"), + ThisUpdate: thisUpdate, + } + + ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer)) + require.NoError(t, err) + + handler := func(rw http.ResponseWriter, req *http.Request) { + ct := req.Header.Get("Content-Type") + assert.Equal(t, "application/ocsp-request", ct) + + reqBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = ocsp.ParseRequest(reqBytes) + require.NoError(t, err) + + rw.Header().Set("Content-Type", "application/ocsp-response") + + _, err = rw.Write(ocspResponse) + require.NoError(t, err) + } + + responder := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(responder.Close) + + responderStatusNotOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(responderStatusNotOK.Close) + + ocspStapler := newOCSPStapler(nil) + ocspStapler.client = &http.Client{Timeout: time.Second} + + entry := &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responder.URL}, + } + err = ocspStapler.updateStaple(t.Context(), entry) + require.NoError(t, err) + + assert.Equal(t, ocspResponse, entry.staple) + assert.NotZero(t, entry.nextUpdate) + assert.Greater(t, time.Now(), entry.nextUpdate) +} + +func TestOCSPStapler_updateStaples(t *testing.T) { + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + thisUpdate, err := time.Parse("2006-01-02", "2025-01-01") + require.NoError(t, err) + nextUpdate, err := time.Parse("2006-01-02", "2025-01-02") + require.NoError(t, err) + stapleUpdate := thisUpdate.Add(nextUpdate.Sub(thisUpdate) / 2) + + ocspResponseTmpl := ocsp.Response{ + SerialNumber: leafCert.Leaf.SerialNumber, + TBSResponseData: []byte("foo"), + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + } + + ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer)) + require.NoError(t, err) + + handler := func(rw http.ResponseWriter, req *http.Request) { + ct := req.Header.Get("Content-Type") + assert.Equal(t, "application/ocsp-request", ct) + + reqBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = ocsp.ParseRequest(reqBytes) + require.NoError(t, err) + + rw.Header().Set("Content-Type", "application/ocsp-response") + + _, err = rw.Write(ocspResponse) + require.NoError(t, err) + } + + responder := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(responder.Close) + + ocspStapler := newOCSPStapler(nil) + ocspStapler.client = &http.Client{Timeout: time.Second} + + // nil staple entry + ocspStapler.cache.Set("nilStaple", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responder.URL}, + nextUpdate: time.Now().Add(-time.Hour), + }, cache.NoExpiration) + // staple entry with nextUpdate in the past + ocspStapler.cache.Set("toUpdate", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responder.URL}, + staple: []byte("foo"), + nextUpdate: time.Now().Add(-time.Hour), + }, cache.NoExpiration) + // staple entry with nextUpdate in the future + inOneHour := time.Now().Add(time.Hour) + ocspStapler.cache.Set("noUpdate", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + responders: []string{responder.URL}, + staple: []byte("foo"), + nextUpdate: inOneHour, + }, cache.NoExpiration) + + ocspStapler.updateStaples(t.Context()) + + nilStaple, ok := ocspStapler.cache.Get("nilStaple") + require.True(t, ok) + + assert.Equal(t, ocspResponse, nilStaple.(*ocspEntry).staple) + assert.Equal(t, stapleUpdate.UTC(), nilStaple.(*ocspEntry).nextUpdate) + + toUpdate, ok := ocspStapler.cache.Get("toUpdate") + require.True(t, ok) + + assert.Equal(t, ocspResponse, toUpdate.(*ocspEntry).staple) + assert.Equal(t, stapleUpdate.UTC(), nilStaple.(*ocspEntry).nextUpdate) + + noUpdate, ok := ocspStapler.cache.Get("noUpdate") + require.True(t, ok) + + assert.Equal(t, []byte("foo"), noUpdate.(*ocspEntry).staple) + assert.Equal(t, inOneHour, noUpdate.(*ocspEntry).nextUpdate) +} diff --git a/pkg/tls/tlsmanager.go b/pkg/tls/tlsmanager.go index 7fcbeab76..2c4a9048c 100644 --- a/pkg/tls/tlsmanager.go +++ b/pkg/tls/tlsmanager.go @@ -6,7 +6,9 @@ import ( "crypto/x509" "errors" "fmt" + "hash/fnv" "slices" + "strconv" "strings" "sync" @@ -43,6 +45,11 @@ func getCipherSuites() []string { return ciphers } +// OCSPConfig contains the OCSP configuration. +type OCSPConfig struct { + ResponderOverrides map[string]string `description:"Defines a map of OCSP responders to replace for querying OCSP servers." json:"responderOverrides,omitempty" toml:"responderOverrides,omitempty" yaml:"responderOverrides,omitempty"` +} + // Manager is the TLS option/store/configuration factory. type Manager struct { lock sync.RWMutex @@ -50,16 +57,33 @@ type Manager struct { stores map[string]*CertificateStore configs map[string]Options certs []*CertAndStores + + // As of today, the TLS manager contains and is responsible for creating/starting the OCSP ocspStapler. + // It would likely have been a Configuration listener but this implies that certs are re-parsed. + // But this would probably have impact on resource consumption. + ocspStapler *ocspStapler } // NewManager creates a new Manager. -func NewManager() *Manager { - return &Manager{ +func NewManager(ocspConfig *OCSPConfig) *Manager { + manager := &Manager{ stores: map[string]*CertificateStore{}, configs: map[string]Options{ "default": DefaultTLSOptions, }, } + + if ocspConfig != nil { + manager.ocspStapler = newOCSPStapler(ocspConfig.ResponderOverrides) + } + + return manager +} + +func (m *Manager) Run(ctx context.Context) { + if m.ocspStapler != nil { + m.ocspStapler.Run(ctx) + } } // UpdateConfigs updates the TLS* configuration options. @@ -91,7 +115,14 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co m.storesConfig[tlsalpn01.ACMETLS1Protocol] = Store{} } - storesCertificates := make(map[string]map[string]*tls.Certificate) + storesCertificates := make(map[string]map[string]*CertificateData) + + // Define the TTL for all the cache entries with no TTL. + // This will discard entries that are not used anymore. + if m.ocspStapler != nil { + m.ocspStapler.ResetTTL() + } + for _, conf := range certs { if len(conf.Stores) == 0 { log.Ctx(ctx).Debug().MsgFunc(func() string { @@ -101,24 +132,49 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co conf.Stores = []string{DefaultTLSStoreName} } - for _, store := range conf.Stores { - logger := log.Ctx(ctx).With().Str(logs.TLSStoreName, store).Logger() + cert, SANs, err := parseCertificate(&conf.Certificate) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msgf("Unable to parse certificate %s", conf.Certificate.GetTruncatedCertificateName()) + continue + } + var certHash string + if m.ocspStapler != nil && len(cert.Leaf.OCSPServer) > 0 { + certHash = hashRawCert(cert.Leaf.Raw) + + issuer := cert.Leaf + if len(cert.Certificate) > 1 { + issuer, err = x509.ParseCertificate(cert.Certificate[1]) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msgf("Unable to parse issuer certificate %s", conf.Certificate.GetTruncatedCertificateName()) + continue + } + } + + if err := m.ocspStapler.Upsert(certHash, cert.Leaf, issuer); err != nil { + log.Ctx(ctx).Error().Err(err).Msgf("Unable to upsert OCSP certificate %s", conf.Certificate.GetTruncatedCertificateName()) + continue + } + } + + certData := &CertificateData{ + Certificate: &cert, + Hash: certHash, + } + + for _, store := range conf.Stores { if _, ok := m.storesConfig[store]; !ok { m.storesConfig[store] = Store{} } - err := conf.Certificate.AppendCertificate(storesCertificates, store) - if err != nil { - logger.Error().Err(err).Msgf("Unable to append certificate %s to store", conf.Certificate.GetTruncatedCertificateName()) - } + appendCertificate(storesCertificates, SANs, store, certData) } } m.stores = make(map[string]*CertificateStore) for storeName, storeConfig := range m.storesConfig { - st := NewCertificateStore() + st := NewCertificateStore(m.ocspStapler) m.stores[storeName] = st if certs, ok := storesCertificates[storeName]; ok { @@ -133,13 +189,17 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co logger := log.Ctx(ctx).With().Str(logs.TLSStoreName, storeName).Logger() ctxStore := logger.WithContext(ctx) - certificate, err := getDefaultCertificate(ctxStore, storeConfig, st) + certificate, err := m.getDefaultCertificate(ctxStore, storeConfig, st) if err != nil { logger.Error().Err(err).Msg("Error while creating certificate store") } st.DefaultCertificate = certificate } + + if m.ocspStapler != nil { + m.ocspStapler.ForceStapleUpdates() + } } // sanitizeDomains sanitizes the domain definition Main and SANS, @@ -226,7 +286,8 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) { } log.Debug().Msgf("Serving default certificate for request: %q", domainToCheck) - return store.DefaultCertificate, nil + + return store.GetDefaultCertificate(), nil } return tlsConfig, err @@ -245,8 +306,8 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate { // We iterate over all the certificates. if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil { - for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*tls.Certificate) { - x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) + for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) { + x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0]) if err != nil { continue } @@ -256,7 +317,7 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate { } if defaultStore.DefaultCertificate != nil { - x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate[0]) + x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0]) if err != nil { return certificates } @@ -289,9 +350,9 @@ func (m *Manager) GetStore(storeName string) *CertificateStore { return m.getStore(storeName) } -func getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateStore) (*tls.Certificate, error) { +func (m *Manager) getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateStore) (*CertificateData, error) { if tlsStore.DefaultCertificate != nil { - cert, err := buildDefaultCertificate(tlsStore.DefaultCertificate) + cert, err := m.buildDefaultCertificate(tlsStore.DefaultCertificate) if err != nil { return nil, err } @@ -304,22 +365,65 @@ func getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateS return nil, err } + defaultCertificate := &CertificateData{ + Certificate: defaultCert, + } + if tlsStore.DefaultGeneratedCert != nil && tlsStore.DefaultGeneratedCert.Domain != nil && tlsStore.DefaultGeneratedCert.Resolver != "" { domains, err := sanitizeDomains(*tlsStore.DefaultGeneratedCert.Domain) if err != nil { - return defaultCert, fmt.Errorf("falling back to the internal generated certificate because invalid domains: %w", err) + return defaultCertificate, fmt.Errorf("falling back to the internal generated certificate because invalid domains: %w", err) } defaultACMECert := st.GetCertificate(domains) if defaultACMECert == nil { - return defaultCert, fmt.Errorf("unable to find certificate for domains %q: falling back to the internal generated certificate", strings.Join(domains, ",")) + return defaultCertificate, fmt.Errorf("unable to find certificate for domains %q: falling back to the internal generated certificate", strings.Join(domains, ",")) } return defaultACMECert, nil } log.Ctx(ctx).Debug().Msg("No default certificate, fallback to the internal generated certificate") - return defaultCert, nil + return defaultCertificate, nil +} + +func (m *Manager) buildDefaultCertificate(defaultCertificate *Certificate) (*CertificateData, error) { + certFile, err := defaultCertificate.CertFile.Read() + if err != nil { + return nil, fmt.Errorf("failed to get cert file content: %w", err) + } + + keyFile, err := defaultCertificate.KeyFile.Read() + if err != nil { + return nil, fmt.Errorf("failed to get key file content: %w", err) + } + + cert, err := tls.X509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("failed to load X509 key pair: %w", err) + } + + var certHash string + if m.ocspStapler != nil && len(cert.Leaf.OCSPServer) > 0 { + certHash = hashRawCert(cert.Leaf.Raw) + + issuer := cert.Leaf + if len(cert.Certificate) > 1 { + issuer, err = x509.ParseCertificate(cert.Certificate[1]) + if err != nil { + return nil, fmt.Errorf("parsing issuer certificate %s: %w", defaultCertificate.GetTruncatedCertificateName(), err) + } + } + + if err := m.ocspStapler.Upsert(certHash, cert.Leaf, issuer); err != nil { + return nil, fmt.Errorf("upserting OCSP certificate %s: %w", defaultCertificate.GetTruncatedCertificateName(), err) + } + } + + return &CertificateData{ + Certificate: &cert, + Hash: certHash, + }, nil } // creates a TLS config that allows terminating HTTPS for multiple domains using SNI. @@ -412,20 +516,10 @@ func buildTLSConfig(tlsOption Options) (*tls.Config, error) { return conf, nil } -func buildDefaultCertificate(defaultCertificate *Certificate) (*tls.Certificate, error) { - certFile, err := defaultCertificate.CertFile.Read() - if err != nil { - return nil, fmt.Errorf("failed to get cert file content: %w", err) - } +func hashRawCert(rawCert []byte) string { + hasher := fnv.New64() - keyFile, err := defaultCertificate.KeyFile.Read() - if err != nil { - return nil, fmt.Errorf("failed to get key file content: %w", err) - } - - cert, err := tls.X509KeyPair(certFile, keyFile) - if err != nil { - return nil, fmt.Errorf("failed to load X509 key pair: %w", err) - } - return &cert, nil + // purposely ignoring the error, as no error can be returned from the implementation. + _, _ = hasher.Write(rawCert) + return strconv.FormatUint(hasher.Sum64(), 16) } diff --git a/pkg/tls/tlsmanager_test.go b/pkg/tls/tlsmanager_test.go index f91726b4a..1cee20276 100644 --- a/pkg/tls/tlsmanager_test.go +++ b/pkg/tls/tlsmanager_test.go @@ -1,14 +1,22 @@ package tls import ( + "context" + "crypto" "crypto/tls" "crypto/x509" "encoding/pem" + "io" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/patrickmn/go-cache" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/types" + "golang.org/x/crypto/ocsp" ) // LocalhostCert is a PEM-encoded TLS cert with SAN IPs @@ -76,10 +84,10 @@ func TestTLSInStore(t *testing.T) { }, }} - tlsManager := NewManager() + tlsManager := NewManager(nil) tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) - certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*tls.Certificate) + certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*CertificateData) if len(certs) == 0 { t.Fatal("got error: default store must have TLS certificates.") } @@ -93,7 +101,7 @@ func TestTLSInvalidStore(t *testing.T) { }, }} - tlsManager := NewManager() + tlsManager := NewManager(nil) tlsManager.UpdateConfigs(t.Context(), map[string]Store{ "default": { @@ -104,7 +112,7 @@ func TestTLSInvalidStore(t *testing.T) { }, }, nil, dynamicConfigs) - certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*tls.Certificate) + certs := tlsManager.GetStore("default").DynamicCerts.Get().(map[string]*CertificateData) if len(certs) == 0 { t.Fatal("got error: default store must have TLS certificates.") } @@ -157,7 +165,7 @@ func TestManager_Get(t *testing.T) { }, } - tlsManager := NewManager() + tlsManager := NewManager(nil) tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, dynamicConfigs) for _, test := range testCases { @@ -296,7 +304,7 @@ func TestClientAuth(t *testing.T) { }, } - tlsManager := NewManager() + tlsManager := NewManager(nil) tlsManager.UpdateConfigs(t.Context(), nil, tlsConfigs, nil) for _, test := range testCases { @@ -323,8 +331,108 @@ func TestClientAuth(t *testing.T) { } } +func TestManager_UpdateConfigs_OCSPConfig(t *testing.T) { + leafCert, err := tls.X509KeyPair([]byte(certWithOCSPServer), []byte(certKey)) + require.NoError(t, err) + + issuerCert, err := tls.X509KeyPair([]byte(caCert), []byte(caKey)) + require.NoError(t, err) + + thisUpdate, err := time.Parse("2006-01-02", "2025-01-01") + require.NoError(t, err) + nextUpdate, err := time.Parse("2006-01-02", "2025-01-02") + require.NoError(t, err) + + ocspResponseTmpl := ocsp.Response{ + SerialNumber: leafCert.Leaf.SerialNumber, + TBSResponseData: []byte("foo"), + ThisUpdate: thisUpdate, + NextUpdate: nextUpdate, + } + + ocspResponse, err := ocsp.CreateResponse(leafCert.Leaf, leafCert.Leaf, ocspResponseTmpl, issuerCert.PrivateKey.(crypto.Signer)) + require.NoError(t, err) + + responderCall := make(chan struct{}) + + handler := func(rw http.ResponseWriter, req *http.Request) { + ct := req.Header.Get("Content-Type") + assert.Equal(t, "application/ocsp-request", ct) + + reqBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + + _, err = ocsp.ParseRequest(reqBytes) + require.NoError(t, err) + + rw.Header().Set("Content-Type", "application/ocsp-response") + + _, err = rw.Write(ocspResponse) + require.NoError(t, err) + + responderCall <- struct{}{} + } + + responder := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(responder.Close) + + testContext, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + tlsManager := NewManager(&OCSPConfig{ + ResponderOverrides: map[string]string{ + "ocsp.example.com": responder.URL, + }, + }) + + go tlsManager.Run(testContext) + + tlsManager.ocspStapler.cache.Set("existing", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + staple: []byte("foo"), + nextUpdate: time.Now().Add(time.Hour), + }, cache.NoExpiration) + tlsManager.ocspStapler.cache.Set("existingWithTTL", &ocspEntry{ + leaf: leafCert.Leaf, + issuer: issuerCert.Leaf, + staple: []byte("foo"), + nextUpdate: time.Now().Add(time.Hour), + }, 2*defaultCacheDuration) + + tlsManager.UpdateConfigs(testContext, nil, nil, []*CertAndStores{ + { + Certificate: Certificate{ + CertFile: certWithOCSPServer, + KeyFile: certKey, + }, + }, + }) + + // Asserting that UpdateConfigs resets the expiration for existing entries. + _, expiration, ok := tlsManager.ocspStapler.cache.GetWithExpiration("existing") + require.True(t, ok) + assert.Greater(t, expiration, time.Now()) + // But not for entries with TTL already set. + _, expiration, ok = tlsManager.ocspStapler.cache.GetWithExpiration("existingWithTTL") + require.True(t, ok) + assert.Greater(t, expiration, time.Now().Add(defaultCacheDuration)) + + select { + case <-responderCall: + case <-time.After(3 * time.Second): + t.Fatal("Timeout waiting for OCSP responder call") + } + + assert.Len(t, tlsManager.ocspStapler.cache.Items(), 3) + + certHash := hashRawCert(leafCert.Leaf.Raw) + _, ok = tlsManager.ocspStapler.cache.Get(certHash) + require.True(t, ok) +} + func TestManager_Get_DefaultValues(t *testing.T) { - tlsManager := NewManager() + tlsManager := NewManager(nil) // Ensures we won't break things for Traefik users when updating Go config, _ := tlsManager.Get("default", "default")