@marcel91200 / caddy Mirror · read-only / commits / d7764dfd
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 12, 03:15 UTC

caddytls: Encrypted ClientHello (ECH) (#6862)

* caddytls: Initial commit of Encrypted ClientHello (ECH) * WIP Caddyfile * Fill out Caddyfile support * Enhance godoc comments * Augment, don't overwrite, HTTPS records * WIP * WIP: publication history * Fix republication logic * Apply global DNS module to ACME challenges This allows DNS challenges to be enabled without locally-configured DNS modules * Ignore false positive from prealloc linter * ci: Use only latest Go version (1.24 currently) We no longer support older Go versions, for security benefits. * Remove old commented code Static ECH keys for now * Implement SendAsRetry

Matt Holt committed Mar 5, 2025 at 17:04 UTC d7764dfdbbee04d2f63aa1b05150737dfddc0bcf
17 files changed +1556 -99
.github/workflows/ci.yml
+3 -7
@@ -18,22 +18,18 @@ jobs:
18 # Default is true, cancels jobs for other platforms in the matrix if one fails
19 fail-fast: false
20 matrix:
21 - os:
21 + os:
22 - linux
23 - mac
24 - windows
25 - go:
26 - - '1.23'
25 + go:
26 - '1.24'
27
28 include:
29 # Set the minimum Go patch version for the given Go minor
30 # Usable via ${{ matrix.GO_SEMVER }}
32 - - go: '1.23'
33 - GO_SEMVER: '~1.23.6'
34 -
31 - go: '1.24'
36 - GO_SEMVER: '~1.24.0'
32 + GO_SEMVER: '~1.24.1'
33
34 # Set some variables per OS, usable via ${{ matrix.VAR }}
35 # OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
.github/workflows/cross-build.yml
+3 -7
@@ -15,7 +15,7 @@ jobs:
15 strategy:
16 fail-fast: false
17 matrix:
18 - goos:
18 + goos:
19 - 'aix'
20 - 'linux'
21 - 'solaris'
@@ -26,18 +26,14 @@ jobs:
26 - 'windows'
27 - 'darwin'
28 - 'netbsd'
29 - go:
30 - - '1.23'
29 + go:
30 - '1.24'
31
32 include:
33 # Set the minimum Go patch version for the given Go minor
34 # Usable via ${{ matrix.GO_SEMVER }}
36 - - go: '1.23'
37 - GO_SEMVER: '~1.23.6'
38 -
35 - go: '1.24'
40 - GO_SEMVER: '~1.24.0'
36 + GO_SEMVER: '~1.24.1'
37
38 runs-on: ubuntu-latest
39 continue-on-error: true
.github/workflows/lint.yml
+1 -1
@@ -63,5 +63,5 @@ jobs:
63 - name: govulncheck
64 uses: golang/govulncheck-action@v1
65 with:
66 - go-version-input: '~1.24.0'
66 + go-version-input: '~1.24.1'
67 check-latest: true
.github/workflows/release.yml
+1 -1
@@ -19,7 +19,7 @@ jobs:
19 # Set the minimum Go patch version for the given Go minor
20 # Usable via ${{ matrix.GO_SEMVER }}
21 - go: '1.24'
22 - GO_SEMVER: '~1.24.0'
22 + GO_SEMVER: '~1.24.1'
23
24 runs-on: ${{ matrix.os }}
25 # https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
caddyconfig/httpcaddyfile/builtins.go
+13 -10
@@ -99,7 +99,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
99 // ca <acme_ca_endpoint>
100 // ca_root <pem_file>
101 // key_type [ed25519|p256|p384|rsa2048|rsa4096]
102 -// dns <provider_name> [...]
102 +// dns [<provider_name> [...]] (required, though, if DNS is not configured as global option)
103 // propagation_delay <duration>
104 // propagation_timeout <duration>
105 // resolvers <dns_servers...>
@@ -312,10 +312,6 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
312 certManagers = append(certManagers, certManager)
313
314 case "dns":
315 - if !h.NextArg() {
316 - return nil, h.ArgErr()
317 - }
318 - provName := h.Val()
315 if acmeIssuer == nil {
316 acmeIssuer = new(caddytls.ACMEIssuer)
317 }
@@ -325,12 +321,19 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
321 if acmeIssuer.Challenges.DNS == nil {
322 acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
323 }
328 - modID := "dns.providers." + provName
329 - unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
330 - if err != nil {
331 - return nil, err
324 + // DNS provider configuration optional, since it may be configured globally via the TLS app with global options
325 + if h.NextArg() {
326 + provName := h.Val()
327 + modID := "dns.providers." + provName
328 + unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
329 + if err != nil {
330 + return nil, err
331 + }
332 + acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
333 + } else if h.Option("dns") == nil {
334 + // if DNS is omitted locally, it needs to be configured globally
335 + return nil, h.ArgErr()
336 }
333 - acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
337
338 case "resolvers":
339 args := h.RemainingArgs()
caddyconfig/httpcaddyfile/httptype.go
+9
@@ -1121,6 +1121,12 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
1121 return nil, fmt.Errorf("two policies with same match criteria have conflicting default SNI: %s vs. %s",
1122 cps[i].DefaultSNI, cps[j].DefaultSNI)
1123 }
1124 + if cps[i].FallbackSNI != "" &&
1125 + cps[j].FallbackSNI != "" &&
1126 + cps[i].FallbackSNI != cps[j].FallbackSNI {
1127 + return nil, fmt.Errorf("two policies with same match criteria have conflicting fallback SNI: %s vs. %s",
1128 + cps[i].FallbackSNI, cps[j].FallbackSNI)
1129 + }
1130 if cps[i].ProtocolMin != "" &&
1131 cps[j].ProtocolMin != "" &&
1132 cps[i].ProtocolMin != cps[j].ProtocolMin {
@@ -1161,6 +1167,9 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
1167 if cps[i].DefaultSNI == "" && cps[j].DefaultSNI != "" {
1168 cps[i].DefaultSNI = cps[j].DefaultSNI
1169 }
1170 + if cps[i].FallbackSNI == "" && cps[j].FallbackSNI != "" {
1171 + cps[i].FallbackSNI = cps[j].FallbackSNI
1172 + }
1173 if cps[i].ProtocolMin == "" && cps[j].ProtocolMin != "" {
1174 cps[i].ProtocolMin = cps[j].ProtocolMin
1175 }
caddyconfig/httpcaddyfile/options.go
+69 -20
@@ -19,6 +19,7 @@ import (
19 "strconv"
20
21 "github.com/caddyserver/certmagic"
22 + "github.com/libdns/libdns"
23 "github.com/mholt/acmez/v3/acme"
24
25 "github.com/caddyserver/caddy/v2"
@@ -45,7 +46,7 @@ func init() {
46 RegisterGlobalOption("ocsp_interval", parseOptDuration)
47 RegisterGlobalOption("acme_ca", parseOptSingleString)
48 RegisterGlobalOption("acme_ca_root", parseOptSingleString)
48 - RegisterGlobalOption("acme_dns", parseOptACMEDNS)
49 + RegisterGlobalOption("acme_dns", parseOptDNS)
50 RegisterGlobalOption("acme_eab", parseOptACMEEAB)
51 RegisterGlobalOption("cert_issuer", parseOptCertIssuer)
52 RegisterGlobalOption("skip_install_trust", parseOptTrue)
@@ -62,6 +63,8 @@ func init() {
63 RegisterGlobalOption("log", parseLogOptions)
64 RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
65 RegisterGlobalOption("persist_config", parseOptPersistConfig)
66 + RegisterGlobalOption("dns", parseOptDNS)
67 + RegisterGlobalOption("ech", parseOptECH)
68 }
69
70 func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil }
@@ -238,25 +241,6 @@ func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
241 return caddy.Duration(dur), nil
242 }
243
241 -func parseOptACMEDNS(d *caddyfile.Dispenser, _ any) (any, error) {
242 - if !d.Next() { // consume option name
243 - return nil, d.ArgErr()
244 - }
245 - if !d.Next() { // get DNS module name
246 - return nil, d.ArgErr()
247 - }
248 - modID := "dns.providers." + d.Val()
249 - unm, err := caddyfile.UnmarshalModule(d, modID)
250 - if err != nil {
251 - return nil, err
252 - }
253 - prov, ok := unm.(certmagic.DNSProvider)
254 - if !ok {
255 - return nil, d.Errf("module %s (%T) is not a certmagic.DNSProvider", modID, unm)
256 - }
257 - return prov, nil
258 -}
259 -
244 func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) {
245 eab := new(acme.EAB)
246 d.Next() // consume option name
@@ -570,3 +554,68 @@ func parseOptPreferredChains(d *caddyfile.Dispenser, _ any) (any, error) {
554 d.Next()
555 return caddytls.ParseCaddyfilePreferredChainsOptions(d)
556 }
557 +
558 +func parseOptDNS(d *caddyfile.Dispenser, _ any) (any, error) {
559 + d.Next() // consume option name
560 +
561 + if !d.Next() { // get DNS module name
562 + return nil, d.ArgErr()
563 + }
564 + modID := "dns.providers." + d.Val()
565 + unm, err := caddyfile.UnmarshalModule(d, modID)
566 + if err != nil {
567 + return nil, err
568 + }
569 + switch unm.(type) {
570 + case libdns.RecordGetter,
571 + libdns.RecordSetter,
572 + libdns.RecordAppender,
573 + libdns.RecordDeleter:
574 + default:
575 + return nil, d.Errf("module %s (%T) is not a libdns provider", modID, unm)
576 + }
577 + return unm, nil
578 +}
579 +
580 +func parseOptECH(d *caddyfile.Dispenser, _ any) (any, error) {
581 + d.Next() // consume option name
582 +
583 + ech := new(caddytls.ECH)
584 +
585 + publicNames := d.RemainingArgs()
586 + for _, publicName := range publicNames {
587 + ech.Configs = append(ech.Configs, caddytls.ECHConfiguration{
588 + OuterSNI: publicName,
589 + })
590 + }
591 + if len(ech.Configs) == 0 {
592 + return nil, d.ArgErr()
593 + }
594 +
595 + for nesting := d.Nesting(); d.NextBlock(nesting); {
596 + switch d.Val() {
597 + case "dns":
598 + if !d.Next() {
599 + return nil, d.ArgErr()
600 + }
601 + providerName := d.Val()
602 + modID := "dns.providers." + providerName
603 + unm, err := caddyfile.UnmarshalModule(d, modID)
604 + if err != nil {
605 + return nil, err
606 + }
607 + ech.Publication = append(ech.Publication, &caddytls.ECHPublication{
608 + Configs: publicNames,
609 + PublishersRaw: caddy.ModuleMap{
610 + "dns": caddyconfig.JSON(caddytls.ECHDNSPublisher{
611 + ProviderRaw: caddyconfig.JSONModuleObject(unm, "name", providerName, nil),
612 + }, nil),
613 + },
614 + })
615 + default:
616 + return nil, d.Errf("ech: unrecognized subdirective '%s'", d.Val())
617 + }
618 + }
619 +
620 + return ech, nil
621 +}
caddyconfig/httpcaddyfile/tlsapp.go
+27 -2
@@ -359,6 +359,30 @@ func (st ServerType) buildTLSApp(
359 tlsApp.Automation.OnDemand = onDemand
360 }
361
362 + // set up "global" (to the TLS app) DNS provider config
363 + if globalDNS, ok := options["dns"]; ok && globalDNS != nil {
364 + tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
365 + }
366 +
367 + // set up ECH from Caddyfile options
368 + if ech, ok := options["ech"].(*caddytls.ECH); ok {
369 + tlsApp.EncryptedClientHello = ech
370 +
371 + // outer server names will need certificates, so make sure they're included
372 + // in an automation policy for them that applies any global options
373 + ap, err := newBaseAutomationPolicy(options, warnings, true)
374 + if err != nil {
375 + return nil, warnings, err
376 + }
377 + for _, cfg := range ech.Configs {
378 + ap.SubjectsRaw = append(ap.SubjectsRaw, cfg.OuterSNI)
379 + }
380 + if tlsApp.Automation == nil {
381 + tlsApp.Automation = new(caddytls.AutomationConfig)
382 + }
383 + tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, ap)
384 + }
385 +
386 // if the storage clean interval is a boolean, then it's "off" to disable cleaning
387 if sc, ok := options["storage_check"].(string); ok && sc == "off" {
388 tlsApp.DisableStorageCheck = true
@@ -553,7 +577,8 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
577 if globalPreferredChains != nil && acmeIssuer.PreferredChains == nil {
578 acmeIssuer.PreferredChains = globalPreferredChains.(*caddytls.ChainPreference)
579 }
556 - if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
580 + // only configure alt HTTP and TLS-ALPN ports if the DNS challenge is not enabled (wouldn't hurt, but isn't necessary since the DNS challenge is exclusive of others)
581 + if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
582 if acmeIssuer.Challenges == nil {
583 acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
584 }
@@ -562,7 +587,7 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
587 }
588 acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int)
589 }
565 - if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
590 + if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
591 if acmeIssuer.Challenges == nil {
592 acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
593 }
context.go
+11 -1
@@ -385,6 +385,17 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (any, error
385 return nil, fmt.Errorf("module value cannot be null")
386 }
387
388 + // if this is an app module, keep a reference to it,
389 + // since submodules may need to reference it during
390 + // provisioning (even though the parent app module
391 + // may not be fully provisioned yet; this is the case
392 + // with the tls app's automation policies, which may
393 + // refer to the tls app to check if a global DNS
394 + // module has been configured for DNS challenges)
395 + if appModule, ok := val.(App); ok {
396 + ctx.cfg.apps[id] = appModule
397 + }
398 +
399 ctx.ancestry = append(ctx.ancestry, val)
400
401 if prov, ok := val.(Provisioner); ok {
@@ -471,7 +482,6 @@ func (ctx Context) App(name string) (any, error) {
482 if appRaw != nil {
483 ctx.cfg.AppsRaw[name] = nil // allow GC to deallocate
484 }
474 - ctx.cfg.apps[name] = modVal.(App)
485 return modVal, nil
486 }
487
go.mod
+7 -6
@@ -8,8 +8,9 @@ require (
8 github.com/Masterminds/sprig/v3 v3.3.0
9 github.com/alecthomas/chroma/v2 v2.14.0
10 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b
11 - github.com/caddyserver/certmagic v0.21.7
11 + github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d
12 github.com/caddyserver/zerossl v0.1.3
13 + github.com/cloudflare/circl v1.3.3
14 github.com/dustin/go-humanize v1.0.1
15 github.com/go-chi/chi/v5 v5.0.12
16 github.com/google/cel-go v0.21.0
@@ -36,11 +37,11 @@ require (
37 go.uber.org/automaxprocs v1.6.0
38 go.uber.org/zap v1.27.0
39 go.uber.org/zap/exp v0.3.0
39 - golang.org/x/crypto v0.31.0
40 + golang.org/x/crypto v0.33.0
41 golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9
42 golang.org/x/net v0.33.0
42 - golang.org/x/sync v0.10.0
43 - golang.org/x/term v0.27.0
43 + golang.org/x/sync v0.11.0
44 + golang.org/x/term v0.29.0
45 golang.org/x/time v0.7.0
46 gopkg.in/natefinch/lumberjack.v2 v2.2.1
47 gopkg.in/yaml.v3 v3.0.1
@@ -114,7 +115,7 @@ require (
115 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
116 github.com/jackc/pgtype v1.14.0 // indirect
117 github.com/jackc/pgx/v4 v4.18.3 // indirect
117 - github.com/libdns/libdns v0.2.2
118 + github.com/libdns/libdns v0.2.3
119 github.com/manifoldco/promptui v0.9.0 // indirect
120 github.com/mattn/go-colorable v0.1.13 // indirect
121 github.com/mattn/go-isatty v0.0.20 // indirect
@@ -148,7 +149,7 @@ require (
149 go.uber.org/multierr v1.11.0 // indirect
150 golang.org/x/mod v0.18.0 // indirect
151 golang.org/x/sys v0.30.0
151 - golang.org/x/text v0.21.0 // indirect
152 + golang.org/x/text v0.22.0 // indirect
153 golang.org/x/tools v0.22.0 // indirect
154 google.golang.org/grpc v1.67.1 // indirect
155 google.golang.org/protobuf v1.35.1 // indirect
go.sum
+14 -12
@@ -91,8 +91,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
91 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
92 github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
93 github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
94 -github.com/caddyserver/certmagic v0.21.7 h1:66KJioPFJwttL43KYSWk7ErSmE6LfaJgCQuhm8Sg6fg=
95 -github.com/caddyserver/certmagic v0.21.7/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI=
94 +github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d h1:9zdfQHH838+rS8pmJ73/RSjpbfHGAyxRX1E79F+1zso=
95 +github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI=
96 github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
97 github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
98 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -111,6 +111,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
111 github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
112 github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
113 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
114 +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
115 +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
116 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
117 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
118 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@@ -323,8 +325,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
325 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
326 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
327 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
326 -github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
327 -github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
328 +github.com/libdns/libdns v0.2.3 h1:ba30K4ObwMGB/QTmqUxf3H4/GmUrCAIkMWejeGl12v8=
329 +github.com/libdns/libdns v0.2.3/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
330 github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
331 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
332 github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -596,8 +598,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
598 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
599 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
600 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
599 -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
600 -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
601 +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
602 +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
603 golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9 h1:4cEcP5+OjGppY79LCQ5Go2B1Boix2x0v6pvA01P3FoA=
604 golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
605 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -645,8 +647,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
647 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
648 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
649 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
648 -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
649 -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
650 +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
651 +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
652 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
653 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
654 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -683,8 +685,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
685 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
686 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
687 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
686 -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
687 -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
688 +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
689 +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
690 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
691 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
692 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -695,8 +697,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
697 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
698 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
699 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
698 -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
699 -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
700 +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
701 +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
702 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
703 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
704 golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
modules/caddyhttp/autohttps.go
+5
@@ -205,6 +205,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
205 // for all the hostnames we found, filter them so we have
206 // a deduplicated list of names for which to obtain certs
207 // (only if cert management not disabled for this server)
208 + var echDomains []string
209 if srv.AutoHTTPS.DisableCerts {
210 logger.Warn("skipping automated certificate management for server because it is disabled", zap.String("server_name", srvName))
211 } else {
@@ -231,10 +232,14 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
232 }
233
234 uniqueDomainsForCerts[d] = struct{}{}
235 + echDomains = append(echDomains, d)
236 }
237 }
238 }
239
240 + // let the TLS server know we have some hostnames that could be protected behind ECH
241 + app.tlsApp.RegisterServerNames(echDomains)
242 +
243 // tell the server to use TLS if it is not already doing so
244 if srv.TLSConnPolicies == nil {
245 srv.TLSConnPolicies = caddytls.ConnectionPolicies{new(caddytls.ConnectionPolicy)}
modules/caddytls/acmeissuer.go
+21 -6
@@ -146,15 +146,30 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error {
146 iss.AccountKey = accountKey
147 }
148
149 - // DNS providers
150 - if iss.Challenges != nil && iss.Challenges.DNS != nil && iss.Challenges.DNS.ProviderRaw != nil {
151 - val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw")
152 - if err != nil {
153 - return fmt.Errorf("loading DNS provider module: %v", err)
149 + // DNS challenge provider
150 + if iss.Challenges != nil && iss.Challenges.DNS != nil {
151 + var prov certmagic.DNSProvider
152 + if iss.Challenges.DNS.ProviderRaw != nil {
153 + // a challenge provider has been locally configured - use it
154 + val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw")
155 + if err != nil {
156 + return fmt.Errorf("loading DNS provider module: %v", err)
157 + }
158 + prov = val.(certmagic.DNSProvider)
159 + } else if tlsAppIface, err := ctx.AppIfConfigured("tls"); err == nil {
160 + // no locally configured DNS challenge provider, but if there is
161 + // a global DNS module configured with the TLS app, use that
162 + tlsApp := tlsAppIface.(*TLS)
163 + if tlsApp.dns != nil {
164 + prov = tlsApp.dns.(certmagic.DNSProvider)
165 + }
166 + }
167 + if prov == nil {
168 + return fmt.Errorf("DNS challenge enabled, but no DNS provider configured")
169 }
170 iss.Challenges.DNS.solver = &certmagic.DNS01Solver{
171 DNSManager: certmagic.DNSManager{
157 - DNSProvider: val.(certmagic.DNSProvider),
172 + DNSProvider: prov,
173 TTL: time.Duration(iss.Challenges.DNS.TTL),
174 PropagationDelay: time.Duration(iss.Challenges.DNS.PropagationDelay),
175 PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout),
modules/caddytls/connpolicy.go
+69 -20
@@ -93,7 +93,7 @@ func (cp ConnectionPolicies) Provision(ctx caddy.Context) error {
93
94 // TLSConfig returns a standard-lib-compatible TLS configuration which
95 // selects the first matching policy based on the ClientHello.
96 -func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
96 +func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
97 // using ServerName to match policies is extremely common, especially in configs
98 // with lots and lots of different policies; we can fast-track those by indexing
99 // them by SNI, so we don't have to iterate potentially thousands of policies
@@ -104,6 +104,7 @@ func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
104 for _, m := range p.matchers {
105 if sni, ok := m.(MatchServerName); ok {
106 for _, sniName := range sni {
107 + // index for fast lookups during handshakes
108 indexedBySNI[sniName] = append(indexedBySNI[sniName], p)
109 }
110 }
@@ -111,32 +112,79 @@ func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
112 }
113 }
114
114 - return &tls.Config{
115 - MinVersion: tls.VersionTLS12,
116 - GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
117 - // filter policies by SNI first, if possible, to speed things up
118 - // when there may be lots of policies
119 - possiblePolicies := cp
120 - if indexedPolicies, ok := indexedBySNI[hello.ServerName]; ok {
121 - possiblePolicies = indexedPolicies
115 + getConfigForClient := func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
116 + // filter policies by SNI first, if possible, to speed things up
117 + // when there may be lots of policies
118 + possiblePolicies := cp
119 + if indexedPolicies, ok := indexedBySNI[hello.ServerName]; ok {
120 + possiblePolicies = indexedPolicies
121 + }
122 +
123 + policyLoop:
124 + for _, pol := range possiblePolicies {
125 + for _, matcher := range pol.matchers {
126 + if !matcher.Match(hello) {
127 + continue policyLoop
128 + }
129 }
130 + if pol.Drop {
131 + return nil, fmt.Errorf("dropping connection")
132 + }
133 + return pol.TLSConfig, nil
134 + }
135 +
136 + return nil, fmt.Errorf("no server TLS configuration available for ClientHello: %+v", hello)
137 + }
138
124 - policyLoop:
125 - for _, pol := range possiblePolicies {
126 - for _, matcher := range pol.matchers {
127 - if !matcher.Match(hello) {
128 - continue policyLoop
139 + tlsCfg := &tls.Config{
140 + MinVersion: tls.VersionTLS12,
141 + GetConfigForClient: getConfigForClient,
142 + }
143 +
144 + // enable ECH, if configured
145 + if tlsAppIface, err := ctx.AppIfConfigured("tls"); err == nil {
146 + tlsApp := tlsAppIface.(*TLS)
147 +
148 + if tlsApp.EncryptedClientHello != nil && len(tlsApp.EncryptedClientHello.configs) > 0 {
149 + // if no publication was configured, we apply ECH to all server names by default,
150 + // but the TLS app needs to know what they are in this case, since they don't appear
151 + // in its config (remember, TLS connection policies are used by *other* apps to
152 + // run TLS servers) -- we skip names with placeholders
153 + if tlsApp.EncryptedClientHello.Publication == nil {
154 + var echNames []string
155 + repl := caddy.NewReplacer()
156 + for _, p := range cp {
157 + for _, m := range p.matchers {
158 + if sni, ok := m.(MatchServerName); ok {
159 + for _, name := range sni {
160 + finalName := strings.ToLower(repl.ReplaceAll(name, ""))
161 + echNames = append(echNames, finalName)
162 + }
163 + }
164 }
165 }
131 - if pol.Drop {
132 - return nil, fmt.Errorf("dropping connection")
133 - }
134 - return pol.TLSConfig, nil
166 + tlsApp.RegisterServerNames(echNames)
167 }
168
137 - return nil, fmt.Errorf("no server TLS configuration available for ClientHello: %+v", hello)
138 - },
169 + // TODO: Ideally, ECH keys should be rotated. However, as of Go 1.24, the std lib implementation
170 + // does not support safely modifying the tls.Config's EncryptedClientHelloKeys field.
171 + // So, we implement static ECH keys temporarily. See https://github.com/golang/go/issues/71920.
172 + // Revisit this after Go 1.25 is released and implement key rotation.
173 + var stdECHKeys []tls.EncryptedClientHelloKey
174 + for _, echConfigs := range tlsApp.EncryptedClientHello.configs {
175 + for _, c := range echConfigs {
176 + stdECHKeys = append(stdECHKeys, tls.EncryptedClientHelloKey{
177 + Config: c.configBin,
178 + PrivateKey: c.privKeyBin,
179 + SendAsRetry: c.sendAsRetry,
180 + })
181 + }
182 + }
183 + tlsCfg.EncryptedClientHelloKeys = stdECHKeys
184 + }
185 }
186 +
187 + return tlsCfg
188 }
189
190 // ConnectionPolicy specifies the logic for handling a TLS handshake.
@@ -409,6 +457,7 @@ func (p ConnectionPolicy) SettingsEmpty() bool {
457 p.ProtocolMax == "" &&
458 p.ClientAuthentication == nil &&
459 p.DefaultSNI == "" &&
460 + p.FallbackSNI == "" &&
461 p.InsecureSecretsLog == ""
462 }
463
modules/caddytls/ech.go new
+1074
@@ -0,0 +1,1074 @@
1 +package caddytls
2 +
3 +import (
4 + "context"
5 + "encoding/base64"
6 + "encoding/json"
7 + "errors"
8 + "fmt"
9 + "io/fs"
10 + weakrand "math/rand/v2"
11 + "path"
12 + "strconv"
13 + "strings"
14 + "time"
15 +
16 + "github.com/caddyserver/certmagic"
17 + "github.com/cloudflare/circl/hpke"
18 + "github.com/cloudflare/circl/kem"
19 + "github.com/libdns/libdns"
20 + "go.uber.org/zap"
21 + "golang.org/x/crypto/cryptobyte"
22 +
23 + "github.com/caddyserver/caddy/v2"
24 +)
25 +
26 +func init() {
27 + caddy.RegisterModule(ECHDNSPublisher{})
28 +}
29 +
30 +// ECH enables Encrypted ClientHello (ECH) and configures its management.
31 +//
32 +// Note that, as of Caddy 2.10 (~March 2025), ECH keys are not automatically
33 +// rotated due to a limitation in the Go standard library (see
34 +// https://github.com/golang/go/issues/71920). This should be resolved when
35 +// Go 1.25 is released (~Aug. 2025), and Caddy will be updated to automatically
36 +// rotate ECH keys/configs at that point.
37 +//
38 +// EXPERIMENTAL: Subject to change.
39 +type ECH struct {
40 + // The list of ECH configurations for which to automatically generate
41 + // and rotate keys. At least one is required to enable ECH.
42 + Configs []ECHConfiguration `json:"configs,omitempty"`
43 +
44 + // Publication describes ways to publish ECH configs for clients to
45 + // discover and use. Without publication, most clients will not use
46 + // ECH at all, and those that do will suffer degraded performance.
47 + //
48 + // Most major browsers support ECH by way of publication to HTTPS
49 + // DNS RRs. (This also typically requires that they use DoH or DoT.)
50 + Publication []*ECHPublication `json:"publication,omitempty"`
51 +
52 + // map of public_name to list of configs
53 + configs map[string][]echConfig
54 +}
55 +
56 +// Provision loads or creates ECH configs and returns outer names (for certificate
57 +// management), but does not publish any ECH configs. The DNS module is used as
58 +// a default for later publishing if needed.
59 +func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) {
60 + logger := ctx.Logger().Named("ech")
61 +
62 + // set up publication modules before we need to obtain a lock in storage,
63 + // since this is strictly internal and doesn't require synchronization
64 + for i, pub := range ech.Publication {
65 + mods, err := ctx.LoadModule(pub, "PublishersRaw")
66 + if err != nil {
67 + return nil, fmt.Errorf("loading ECH publication modules: %v", err)
68 + }
69 + for _, modIface := range mods.(map[string]any) {
70 + ech.Publication[i].publishers = append(ech.Publication[i].publishers, modIface.(ECHPublisher))
71 + }
72 + }
73 +
74 + // the rest of provisioning needs an exclusive lock so that instances aren't
75 + // stepping on each other when setting up ECH configs
76 + storage := ctx.Storage()
77 + const echLockName = "ech_provision"
78 + if err := storage.Lock(ctx, echLockName); err != nil {
79 + return nil, err
80 + }
81 + defer func() {
82 + if err := storage.Unlock(ctx, echLockName); err != nil {
83 + logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
84 + }
85 + }()
86 +
87 + var outerNames []string //nolint:prealloc // (FALSE POSITIVE - see https://github.com/alexkohler/prealloc/issues/30)
88 +
89 + // start by loading all the existing configs (even the older ones on the way out,
90 + // since some clients may still be using them if they haven't yet picked up on the
91 + // new configs)
92 + cfgKeys, err := storage.List(ctx, echConfigsKey, false)
93 + if err != nil && !errors.Is(err, fs.ErrNotExist) { // OK if dir doesn't exist; it will be created
94 + return nil, err
95 + }
96 + for _, cfgKey := range cfgKeys {
97 + cfg, err := loadECHConfig(ctx, path.Base(cfgKey))
98 + if err != nil {
99 + return nil, err
100 + }
101 + // if any part of the config's folder was corrupted, the load function will
102 + // clean it up and not return an error, since configs are immutable and
103 + // fairly ephemeral... so just check that we actually got a populated config
104 + if cfg.configBin == nil || cfg.privKeyBin == nil {
105 + continue
106 + }
107 + logger.Debug("loaded ECH config",
108 + zap.String("public_name", cfg.RawPublicName),
109 + zap.Uint8("id", cfg.ConfigID))
110 + ech.configs[cfg.RawPublicName] = append(ech.configs[cfg.RawPublicName], cfg)
111 + outerNames = append(outerNames, cfg.RawPublicName)
112 + }
113 +
114 + // all existing configs are now loaded; see if we need to make any new ones
115 + // based on the input configuration, and also mark the most recent one(s) as
116 + // current/active, so they can be used for ECH retries
117 +
118 + for _, cfg := range ech.Configs {
119 + publicName := strings.ToLower(strings.TrimSpace(cfg.OuterSNI))
120 +
121 + if list, ok := ech.configs[publicName]; ok && len(list) > 0 {
122 + // at least one config with this public name was loaded, so find the
123 + // most recent one and mark it as active to be used with retries
124 + var mostRecentDate time.Time
125 + var mostRecentIdx int
126 + for i, c := range list {
127 + if mostRecentDate.IsZero() || c.meta.Created.After(mostRecentDate) {
128 + mostRecentDate = c.meta.Created
129 + mostRecentIdx = i
130 + }
131 + }
132 + list[mostRecentIdx].sendAsRetry = true
133 + } else {
134 + // no config with this public name was loaded, so create one
135 + echCfg, err := generateAndStoreECHConfig(ctx, publicName)
136 + if err != nil {
137 + return nil, err
138 + }
139 + logger.Debug("generated new ECH config",
140 + zap.String("public_name", echCfg.RawPublicName),
141 + zap.Uint8("id", echCfg.ConfigID))
142 + ech.configs[publicName] = append(ech.configs[publicName], echCfg)
143 + outerNames = append(outerNames, publicName)
144 + }
145 + }
146 +
147 + return outerNames, nil
148 +}
149 +
150 +func (t *TLS) publishECHConfigs() error {
151 + logger := t.logger.Named("ech")
152 +
153 + // make publication exclusive, since we don't need to repeat this unnecessarily
154 + storage := t.ctx.Storage()
155 + const echLockName = "ech_publish"
156 + if err := storage.Lock(t.ctx, echLockName); err != nil {
157 + return err
158 + }
159 + defer func() {
160 + if err := storage.Unlock(t.ctx, echLockName); err != nil {
161 + logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
162 + }
163 + }()
164 +
165 + // get the publication config, or use a default if not specified
166 + // (the default publication config should be to publish all ECH
167 + // configs to the app-global DNS provider; if no DNS provider is
168 + // configured, then this whole function is basically a no-op)
169 + publicationList := t.EncryptedClientHello.Publication
170 + if publicationList == nil {
171 + if dnsProv, ok := t.dns.(ECHDNSProvider); ok {
172 + publicationList = []*ECHPublication{
173 + {
174 + publishers: []ECHPublisher{
175 + &ECHDNSPublisher{
176 + provider: dnsProv,
177 + logger: t.logger,
178 + },
179 + },
180 + },
181 + }
182 + }
183 + }
184 +
185 + // for each publication config, build the list of ECH configs to
186 + // publish with it, and figure out which inner names to publish
187 + // to/for, then publish
188 + for _, publication := range publicationList {
189 + // this publication is either configured for specific ECH configs,
190 + // or we just use an implied default of all ECH configs
191 + var echCfgList echConfigList
192 + var configIDs []uint8 // TODO: use IDs or the outer names?
193 + if publication.Configs == nil {
194 + // by default, publish all configs
195 + for _, configs := range t.EncryptedClientHello.configs {
196 + echCfgList = append(echCfgList, configs...)
197 + for _, c := range configs {
198 + configIDs = append(configIDs, c.ConfigID)
199 + }
200 + }
201 + } else {
202 + for _, cfgOuterName := range publication.Configs {
203 + if cfgList, ok := t.EncryptedClientHello.configs[cfgOuterName]; ok {
204 + echCfgList = append(echCfgList, cfgList...)
205 + for _, c := range cfgList {
206 + configIDs = append(configIDs, c.ConfigID)
207 + }
208 + }
209 + }
210 + }
211 +
212 + // marshal the ECH config list as binary for publication
213 + echCfgListBin, err := echCfgList.MarshalBinary()
214 + if err != nil {
215 + return fmt.Errorf("marshaling ECH config list: %v", err)
216 + }
217 +
218 + // now we have our list of ECH configs to publish and the inner names
219 + // to publish for (i.e. the names being protected); iterate each publisher
220 + // and do the publish for any config+name that needs a publish
221 + for _, publisher := range publication.publishers {
222 + publisherKey := publisher.PublisherKey()
223 +
224 + // by default, publish for all (non-outer) server names, unless
225 + // a specific list of names is configured
226 + var serverNamesSet map[string]struct{}
227 + if publication.Domains == nil {
228 + serverNamesSet = make(map[string]struct{}, len(t.serverNames))
229 + for name := range t.serverNames {
230 + serverNamesSet[name] = struct{}{}
231 + }
232 + } else {
233 + serverNamesSet = make(map[string]struct{}, len(publication.Domains))
234 + for _, name := range publication.Domains {
235 + serverNamesSet[name] = struct{}{}
236 + }
237 + }
238 +
239 + // remove any domains from the set which have already had all configs in the
240 + // list published by this publisher, to avoid always re-publishing unnecessarily
241 + for configuredInnerName := range serverNamesSet {
242 + allConfigsPublished := true
243 + for _, cfg := range echCfgList {
244 + // TODO: Potentially utilize the timestamp (map value) for recent-enough publication, instead of just checking for existence
245 + if _, ok := cfg.meta.Publications[publisherKey][configuredInnerName]; !ok {
246 + allConfigsPublished = false
247 + break
248 + }
249 + }
250 + if allConfigsPublished {
251 + delete(serverNamesSet, configuredInnerName)
252 + }
253 + }
254 +
255 + // if all the (inner) domains have had this ECH config list published
256 + // by this publisher, then try the next publication config
257 + if len(serverNamesSet) == 0 {
258 + logger.Debug("ECH config list already published by publisher for associated domains",
259 + zap.Uint8s("config_ids", configIDs),
260 + zap.String("publisher", publisherKey))
261 + continue
262 + }
263 +
264 + // convert the set of names to a slice
265 + dnsNamesToPublish := make([]string, 0, len(serverNamesSet))
266 + for name := range serverNamesSet {
267 + dnsNamesToPublish = append(dnsNamesToPublish, name)
268 + }
269 +
270 + logger.Debug("publishing ECH config list",
271 + zap.Strings("domains", dnsNamesToPublish),
272 + zap.Uint8s("config_ids", configIDs))
273 +
274 + // publish this ECH config list with this publisher
275 + pubTime := time.Now()
276 + err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin)
277 + if err != nil {
278 + t.logger.Error("publishing ECH configuration list",
279 + zap.Strings("for_domains", publication.Domains),
280 + zap.Error(err))
281 + }
282 +
283 + // update publication history, so that we don't unnecessarily republish every time
284 + for _, cfg := range echCfgList {
285 + if cfg.meta.Publications == nil {
286 + cfg.meta.Publications = make(publicationHistory)
287 + }
288 + if _, ok := cfg.meta.Publications[publisherKey]; !ok {
289 + cfg.meta.Publications[publisherKey] = make(map[string]time.Time)
290 + }
291 + for _, name := range dnsNamesToPublish {
292 + cfg.meta.Publications[publisherKey][name] = pubTime
293 + }
294 + metaBytes, err := json.Marshal(cfg.meta)
295 + if err != nil {
296 + return fmt.Errorf("marshaling ECH config metadata: %v", err)
297 + }
298 + metaKey := path.Join(echConfigsKey, strconv.Itoa(int(cfg.ConfigID)), "meta.json")
299 + if err := t.ctx.Storage().Store(t.ctx, metaKey, metaBytes); err != nil {
300 + return fmt.Errorf("storing updated ECH config metadata: %v", err)
301 + }
302 + }
303 + }
304 + }
305 +
306 + return nil
307 +}
308 +
309 +// loadECHConfig loads the config from storage with the given configID.
310 +// An error is not actually returned in some cases the config fails to
311 +// load because in some cases it just means the config ID folder has
312 +// been cleaned up in storage, maybe due to an incomplete set of keys
313 +// or corrupted contents; in any case, the only rectification is to
314 +// delete it and make new keys (an error IS returned if deleting the
315 +// corrupted keys fails, for example). Check the returned echConfig for
316 +// non-nil privKeyBin and configBin values before using.
317 +func loadECHConfig(ctx caddy.Context, configID string) (echConfig, error) {
318 + storage := ctx.Storage()
319 + logger := ctx.Logger()
320 +
321 + cfgIDKey := path.Join(echConfigsKey, configID)
322 + keyKey := path.Join(cfgIDKey, "key.bin")
323 + configKey := path.Join(cfgIDKey, "config.bin")
324 + metaKey := path.Join(cfgIDKey, "meta.json")
325 +
326 + // if loading anything fails, might as well delete this folder and free up
327 + // the config ID; spec is designed to rotate configs frequently anyway
328 + // (I consider it a more serious error if we can't clean up the folder,
329 + // since leaving stray storage keys is confusing)
330 + privKeyBytes, err := storage.Load(ctx, keyKey)
331 + if err != nil {
332 + delErr := storage.Delete(ctx, cfgIDKey)
333 + if delErr != nil {
334 + return echConfig{}, fmt.Errorf("error loading private key (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
335 + }
336 + logger.Warn("could not load ECH private key; deleting its config folder",
337 + zap.String("config_id", configID),
338 + zap.Error(err))
339 + return echConfig{}, nil
340 + }
341 + echConfigBytes, err := storage.Load(ctx, configKey)
342 + if err != nil {
343 + delErr := storage.Delete(ctx, cfgIDKey)
344 + if delErr != nil {
345 + return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
346 + }
347 + logger.Warn("could not load ECH config; deleting its config folder",
348 + zap.String("config_id", configID),
349 + zap.Error(err))
350 + return echConfig{}, nil
351 + }
352 + var cfg echConfig
353 + if err := cfg.UnmarshalBinary(echConfigBytes); err != nil {
354 + delErr := storage.Delete(ctx, cfgIDKey)
355 + if delErr != nil {
356 + return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
357 + }
358 + logger.Warn("could not load ECH config; deleted its config folder",
359 + zap.String("config_id", configID),
360 + zap.Error(err))
361 + return echConfig{}, nil
362 + }
363 + metaBytes, err := storage.Load(ctx, metaKey)
364 + if err != nil {
365 + delErr := storage.Delete(ctx, cfgIDKey)
366 + if delErr != nil {
367 + return echConfig{}, fmt.Errorf("error loading ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
368 + }
369 + logger.Warn("could not load ECH metadata; deleted its config folder",
370 + zap.String("config_id", configID),
371 + zap.Error(err))
372 + return echConfig{}, nil
373 + }
374 + var meta echConfigMeta
375 + if err := json.Unmarshal(metaBytes, &meta); err != nil {
376 + // even though it's just metadata, reset the whole config since we can't reliably maintain it
377 + delErr := storage.Delete(ctx, cfgIDKey)
378 + if delErr != nil {
379 + return echConfig{}, fmt.Errorf("error decoding ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
380 + }
381 + logger.Warn("could not JSON-decode ECH metadata; deleted its config folder",
382 + zap.String("config_id", configID),
383 + zap.Error(err))
384 + return echConfig{}, nil
385 + }
386 +
387 + cfg.privKeyBin = privKeyBytes
388 + cfg.configBin = echConfigBytes
389 + cfg.meta = meta
390 +
391 + return cfg, nil
392 +}
393 +
394 +func generateAndStoreECHConfig(ctx caddy.Context, publicName string) (echConfig, error) {
395 + // Go currently has very strict requirements for server-side ECH configs,
396 + // to quote the Go 1.24 godoc (with typos of AEAD IDs corrected):
397 + //
398 + // "Config should be a marshalled ECHConfig associated with PrivateKey. This
399 + // must match the config provided to clients byte-for-byte. The config
400 + // should only specify the DHKEM(X25519, HKDF-SHA256) KEM ID (0x0020), the
401 + // HKDF-SHA256 KDF ID (0x0001), and a subset of the following AEAD IDs:
402 + // AES-128-GCM (0x0001), AES-256-GCM (0x0002), ChaCha20Poly1305 (0x0003)."
403 + //
404 + // So we need to be sure we generate a config within these parameters
405 + // so the Go TLS server can use it.
406 +
407 + // generate a key pair
408 + const kemChoice = hpke.KEM_X25519_HKDF_SHA256
409 + publicKey, privateKey, err := kemChoice.Scheme().GenerateKeyPair()
410 + if err != nil {
411 + return echConfig{}, err
412 + }
413 +
414 + // find an available config ID
415 + configID, err := newECHConfigID(ctx)
416 + if err != nil {
417 + return echConfig{}, fmt.Errorf("generating unique config ID: %v", err)
418 + }
419 +
420 + echCfg := echConfig{
421 + PublicKey: publicKey,
422 + Version: draftTLSESNI22,
423 + ConfigID: configID,
424 + RawPublicName: publicName,
425 + KEMID: kemChoice,
426 + CipherSuites: []hpkeSymmetricCipherSuite{
427 + {
428 + KDFID: hpke.KDF_HKDF_SHA256,
429 + AEADID: hpke.AEAD_AES128GCM,
430 + },
431 + {
432 + KDFID: hpke.KDF_HKDF_SHA256,
433 + AEADID: hpke.AEAD_AES256GCM,
434 + },
435 + {
436 + KDFID: hpke.KDF_HKDF_SHA256,
437 + AEADID: hpke.AEAD_ChaCha20Poly1305,
438 + },
439 + },
440 + sendAsRetry: true,
441 + }
442 + meta := echConfigMeta{
443 + Created: time.Now(),
444 + }
445 +
446 + privKeyBytes, err := privateKey.MarshalBinary()
447 + if err != nil {
448 + return echConfig{}, fmt.Errorf("marshaling ECH private key: %v", err)
449 + }
450 + echConfigBytes, err := echCfg.MarshalBinary()
451 + if err != nil {
452 + return echConfig{}, fmt.Errorf("marshaling ECH config: %v", err)
453 + }
454 + metaBytes, err := json.Marshal(meta)
455 + if err != nil {
456 + return echConfig{}, fmt.Errorf("marshaling ECH config metadata: %v", err)
457 + }
458 +
459 + parentKey := path.Join(echConfigsKey, strconv.Itoa(int(configID)))
460 + keyKey := path.Join(parentKey, "key.bin")
461 + configKey := path.Join(parentKey, "config.bin")
462 + metaKey := path.Join(parentKey, "meta.json")
463 +
464 + if err := ctx.Storage().Store(ctx, keyKey, privKeyBytes); err != nil {
465 + return echConfig{}, fmt.Errorf("storing ECH private key: %v", err)
466 + }
467 + if err := ctx.Storage().Store(ctx, configKey, echConfigBytes); err != nil {
468 + return echConfig{}, fmt.Errorf("storing ECH config: %v", err)
469 + }
470 + if err := ctx.Storage().Store(ctx, metaKey, metaBytes); err != nil {
471 + return echConfig{}, fmt.Errorf("storing ECH config metadata: %v", err)
472 + }
473 +
474 + echCfg.privKeyBin = privKeyBytes
475 + echCfg.configBin = echConfigBytes // this contains the public key
476 + echCfg.meta = meta
477 +
478 + return echCfg, nil
479 +}
480 +
481 +// ECH represents an Encrypted ClientHello configuration.
482 +//
483 +// EXPERIMENTAL: Subject to change.
484 +type ECHConfiguration struct {
485 + // The public server name that will be used in the outer ClientHello. This
486 + // should be a domain name for which this server is authoritative, because
487 + // Caddy will try to provision a certificate for this name. As an outer
488 + // SNI, it is never used for application data (HTTPS, etc.), but it is
489 + // necessary for securely reconciling inconsistent client state without
490 + // breakage and brittleness.
491 + OuterSNI string `json:"outer_sni,omitempty"`
492 +}
493 +
494 +// ECHPublication configures publication of ECH config(s).
495 +type ECHPublication struct {
496 + // TODO: Should these first two fields be called outer_sni and inner_sni ?
497 +
498 + // The list of ECH configurations to publish, identified by public name.
499 + // If not set, all configs will be included for publication by default.
500 + Configs []string `json:"configs,omitempty"`
501 +
502 + // The list of domain names which are protected with the associated ECH
503 + // configurations ("inner names"). Not all publishers may require this
504 + // information, but some, like the DNS publisher, do. (The DNS publisher,
505 + // for example, needs to know for which domain(s) to create DNS records.)
506 + //
507 + // If not set, all server names registered with the TLS module will be
508 + // added to this list implicitly. (Other Caddy apps that use the TLS
509 + // module automatically register their configured server names for this
510 + // purpose. For example, the HTTP server registers the hostnames for
511 + // which it applies automatic HTTPS.)
512 + //
513 + // Names in this list should not appear in any other publication config
514 + // object with the same publishers, since the publications will likely
515 + // overwrite each other.
516 + //
517 + // NOTE: In order to publish ECH configs for domains configured for
518 + // On-Demand TLS that are not explicitly enumerated elsewhere in the
519 + // config, those domain names will have to be listed here. The only
520 + // time Caddy knows which domains it is serving with On-Demand TLS is
521 + // handshake-time, which is too late for publishing ECH configs; it
522 + // means the first connections would not protect the server names,
523 + // revealing that information to observers, and thus defeating the
524 + // purpose of ECH. Hence the need to list them here so Caddy can
525 + // proactively publish ECH configs before clients connect with those
526 + // server names in plaintext.
527 + Domains []string `json:"domains,omitempty"`
528 +
529 + // How to publish the ECH configurations so clients can know to use them.
530 + // Note that ECH configs are only published when they are newly created,
531 + // so adding or changing publishers after the fact will have no effect
532 + // with existing ECH configs. The next time a config is generated (including
533 + // when a key is rotated), the current publication modules will be utilized.
534 + PublishersRaw caddy.ModuleMap `json:"publishers,omitempty" caddy:"namespace=tls.ech.publishers"`
535 + publishers []ECHPublisher
536 +}
537 +
538 +// ECHDNSProvider can service DNS entries for ECH purposes.
539 +type ECHDNSProvider interface {
540 + libdns.RecordGetter
541 + libdns.RecordSetter
542 +}
543 +
544 +// ECHDNSPublisher configures how to publish an ECH configuration to
545 +// DNS records for the specified domains.
546 +//
547 +// EXPERIMENTAL: Subject to change.
548 +type ECHDNSPublisher struct {
549 + // The DNS provider module which will establish the HTTPS record(s).
550 + ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"`
551 + provider ECHDNSProvider
552 +
553 + logger *zap.Logger
554 +}
555 +
556 +// CaddyModule returns the Caddy module information.
557 +func (ECHDNSPublisher) CaddyModule() caddy.ModuleInfo {
558 + return caddy.ModuleInfo{
559 + ID: "tls.ech.publishers.dns",
560 + New: func() caddy.Module { return new(ECHDNSPublisher) },
561 + }
562 +}
563 +
564 +func (dnsPub ECHDNSPublisher) Provision(ctx caddy.Context) error {
565 + dnsProvMod, err := ctx.LoadModule(dnsPub, "ProviderRaw")
566 + if err != nil {
567 + return fmt.Errorf("loading ECH DNS provider module: %v", err)
568 + }
569 + prov, ok := dnsProvMod.(ECHDNSProvider)
570 + if !ok {
571 + return fmt.Errorf("ECH DNS provider module is not an ECH DNS Provider: %v", err)
572 + }
573 + dnsPub.provider = prov
574 + dnsPub.logger = ctx.Logger()
575 + return nil
576 +}
577 +
578 +// PublisherKey returns the name of the DNS provider module.
579 +// We intentionally omit specific provider configuration (or a hash thereof,
580 +// since the config is likely sensitive, potentially containing an API key)
581 +// because it is unlikely that specific configuration, such as an API key,
582 +// is relevant to unique key use as an ECH config publisher.
583 +func (dnsPub ECHDNSPublisher) PublisherKey() string {
584 + return string(dnsPub.provider.(caddy.Module).CaddyModule().ID)
585 +}
586 +
587 +// PublishECHConfigList publishes the given ECH config list to the given DNS names.
588 +func (dnsPub *ECHDNSPublisher) PublishECHConfigList(ctx context.Context, innerNames []string, configListBin []byte) error {
589 + nameservers := certmagic.RecursiveNameservers(nil) // TODO: we could make resolvers configurable
590 +
591 + for _, domain := range innerNames {
592 + zone, err := certmagic.FindZoneByFQDN(ctx, dnsPub.logger, domain, nameservers)
593 + if err != nil {
594 + dnsPub.logger.Error("could not determine zone for domain",
595 + zap.String("domain", domain),
596 + zap.Error(err))
597 + continue
598 + }
599 +
600 + // get any existing HTTPS record for this domain, and augment
601 + // our ech SvcParamKey with any other existing SvcParams
602 + recs, err := dnsPub.provider.GetRecords(ctx, zone)
603 + if err != nil {
604 + dnsPub.logger.Error("unable to get existing DNS records to publish ECH data to HTTPS DNS record",
605 + zap.String("domain", domain),
606 + zap.Error(err))
607 + continue
608 + }
609 + relName := libdns.RelativeName(domain+".", zone)
610 + var httpsRec libdns.Record
611 + for _, rec := range recs {
612 + if rec.Name == relName && rec.Type == "HTTPS" && (rec.Target == "" || rec.Target == ".") {
613 + httpsRec = rec
614 + }
615 + }
616 + params := make(svcParams)
617 + if httpsRec.Value != "" {
618 + params, err = parseSvcParams(httpsRec.Value)
619 + if err != nil {
620 + dnsPub.logger.Error("unable to parse existing DNS record to publish ECH data to HTTPS DNS record",
621 + zap.String("domain", domain),
622 + zap.String("https_rec_value", httpsRec.Value),
623 + zap.Error(err))
624 + continue
625 + }
626 + }
627 +
628 + // overwrite only the ech SvcParamKey
629 + params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
630 +
631 + // publish record
632 + _, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{
633 + {
634 + // HTTPS and SVCB RRs: RFC 9460 (https://www.rfc-editor.org/rfc/rfc9460)
635 + Type: "HTTPS",
636 + Name: relName,
637 + Priority: 2, // allows a manual override with priority 1
638 + Target: ".",
639 + Value: params.String(),
640 + TTL: 1 * time.Minute, // TODO: for testing only
641 + },
642 + })
643 + if err != nil {
644 + dnsPub.logger.Error("unable to publish ECH data to HTTPS DNS record",
645 + zap.String("domain", domain),
646 + zap.Error(err))
647 + continue
648 + }
649 + }
650 +
651 + return nil
652 +}
653 +
654 +// echConfig represents an ECHConfig from the specification,
655 +// [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html).
656 +type echConfig struct {
657 + // "The version of ECH for which this configuration is used.
658 + // The version is the same as the code point for the
659 + // encrypted_client_hello extension. Clients MUST ignore any
660 + // ECHConfig structure with a version they do not support."
661 + Version uint16
662 +
663 + // The "length" and "contents" fields defined next in the
664 + // structure are implicitly taken care of by cryptobyte
665 + // when encoding the following fields:
666 +
667 + // HpkeKeyConfig fields:
668 + ConfigID uint8
669 + KEMID hpke.KEM
670 + PublicKey kem.PublicKey
671 + CipherSuites []hpkeSymmetricCipherSuite
672 +
673 + // ECHConfigContents fields:
674 + MaxNameLength uint8
675 + RawPublicName string
676 + RawExtensions []byte
677 +
678 + // these fields are not part of the spec, but are here for
679 + // our use when setting up TLS servers or maintenance
680 + configBin []byte
681 + privKeyBin []byte
682 + meta echConfigMeta
683 + sendAsRetry bool
684 +}
685 +
686 +func (echCfg echConfig) MarshalBinary() ([]byte, error) {
687 + var b cryptobyte.Builder
688 + if err := echCfg.marshalBinary(&b); err != nil {
689 + return nil, err
690 + }
691 + return b.Bytes()
692 +}
693 +
694 +// UnmarshalBinary decodes the data back into an ECH config.
695 +//
696 +// Borrowed from github.com/OmarTariq612/goech with modifications.
697 +// Original code: Copyright (c) 2023 Omar Tariq AbdEl-Raziq
698 +func (echCfg *echConfig) UnmarshalBinary(data []byte) error {
699 + var content cryptobyte.String
700 + b := cryptobyte.String(data)
701 +
702 + if !b.ReadUint16(&echCfg.Version) {
703 + return errInvalidLen
704 + }
705 + if echCfg.Version != draftTLSESNI22 {
706 + return fmt.Errorf("supported version must be %d: got %d", draftTLSESNI22, echCfg.Version)
707 + }
708 +
709 + if !b.ReadUint16LengthPrefixed(&content) || !b.Empty() {
710 + return errInvalidLen
711 + }
712 +
713 + var t cryptobyte.String
714 + var pk []byte
715 +
716 + if !content.ReadUint8(&echCfg.ConfigID) ||
717 + !content.ReadUint16((*uint16)(&echCfg.KEMID)) ||
718 + !content.ReadUint16LengthPrefixed(&t) ||
719 + !t.ReadBytes(&pk, len(t)) ||
720 + !content.ReadUint16LengthPrefixed(&t) ||
721 + len(t)%4 != 0 /* the length of (KDFs and AEADs) must be divisible by 4 */ {
722 + return errInvalidLen
723 + }
724 +
725 + if !echCfg.KEMID.IsValid() {
726 + return fmt.Errorf("invalid KEM ID: %d", echCfg.KEMID)
727 + }
728 +
729 + var err error
730 + if echCfg.PublicKey, err = echCfg.KEMID.Scheme().UnmarshalBinaryPublicKey(pk); err != nil {
731 + return fmt.Errorf("parsing public_key: %w", err)
732 + }
733 +
734 + echCfg.CipherSuites = echCfg.CipherSuites[:0]
735 +
736 + for !t.Empty() {
737 + var hpkeKDF, hpkeAEAD uint16
738 + if !t.ReadUint16(&hpkeKDF) || !t.ReadUint16(&hpkeAEAD) {
739 + // we have already checked that the length is divisible by 4
740 + panic("this must not happen")
741 + }
742 + if !hpke.KDF(hpkeKDF).IsValid() {
743 + return fmt.Errorf("invalid KDF ID: %d", hpkeKDF)
744 + }
745 + if !hpke.AEAD(hpkeAEAD).IsValid() {
746 + return fmt.Errorf("invalid AEAD ID: %d", hpkeAEAD)
747 + }
748 + echCfg.CipherSuites = append(echCfg.CipherSuites, hpkeSymmetricCipherSuite{
749 + KDFID: hpke.KDF(hpkeKDF),
750 + AEADID: hpke.AEAD(hpkeAEAD),
751 + })
752 + }
753 +
754 + var rawPublicName []byte
755 + if !content.ReadUint8(&echCfg.MaxNameLength) ||
756 + !content.ReadUint8LengthPrefixed(&t) ||
757 + !t.ReadBytes(&rawPublicName, len(t)) ||
758 + !content.ReadUint16LengthPrefixed(&t) ||
759 + !t.ReadBytes(&echCfg.RawExtensions, len(t)) ||
760 + !content.Empty() {
761 + return errInvalidLen
762 + }
763 + echCfg.RawPublicName = string(rawPublicName)
764 +
765 + return nil
766 +}
767 +
768 +var errInvalidLen = errors.New("invalid length")
769 +
770 +// marshalBinary writes this config to the cryptobyte builder. If there is an error,
771 +// it will occur before any writes have happened.
772 +func (echCfg echConfig) marshalBinary(b *cryptobyte.Builder) error {
773 + pk, err := echCfg.PublicKey.MarshalBinary()
774 + if err != nil {
775 + return err
776 + }
777 + if l := len(echCfg.RawPublicName); l == 0 || l > 255 {
778 + return fmt.Errorf("public name length (%d) must be in the range 1-255", l)
779 + }
780 +
781 + b.AddUint16(echCfg.Version)
782 + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { // "length" field
783 + b.AddUint8(echCfg.ConfigID)
784 + b.AddUint16(uint16(echCfg.KEMID))
785 + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
786 + b.AddBytes(pk)
787 + })
788 + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
789 + for _, cs := range echCfg.CipherSuites {
790 + b.AddUint16(uint16(cs.KDFID))
791 + b.AddUint16(uint16(cs.AEADID))
792 + }
793 + })
794 + b.AddUint8(uint8(min(len(echCfg.RawPublicName)+16, 255)))
795 + b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
796 + b.AddBytes([]byte(echCfg.RawPublicName))
797 + })
798 + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
799 + child.AddBytes(echCfg.RawExtensions)
800 + })
801 + })
802 +
803 + return nil
804 +}
805 +
806 +type hpkeSymmetricCipherSuite struct {
807 + KDFID hpke.KDF
808 + AEADID hpke.AEAD
809 +}
810 +
811 +type echConfigList []echConfig
812 +
813 +func (cl echConfigList) MarshalBinary() ([]byte, error) {
814 + var b cryptobyte.Builder
815 + var err error
816 +
817 + // the list's length prefixes the list, as with most opaque values
818 + b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
819 + for _, cfg := range cl {
820 + if err = cfg.marshalBinary(b); err != nil {
821 + break
822 + }
823 + }
824 + })
825 + if err != nil {
826 + return nil, err
827 + }
828 +
829 + return b.Bytes()
830 +}
831 +
832 +func newECHConfigID(ctx caddy.Context) (uint8, error) {
833 + // uint8 can be 0-255 inclusive
834 + const uint8Range = 256
835 +
836 + // avoid repeating storage checks
837 + tried := make([]bool, uint8Range)
838 +
839 + // Try to find an available number with random rejection sampling;
840 + // i.e. choose a random number and see if it's already taken.
841 + // The hard limit on how many times we try to find an available
842 + // number is flexible... in theory, assuming uniform distribution,
843 + // 256 attempts should make each possible value show up exactly
844 + // once, but obviously that won't be the case. We can try more
845 + // times to try to ensure that every number gets a chance, which
846 + // is especially useful if few are available, or we can lower it
847 + // if we assume we should have found an available value by then
848 + // and want to limit runtime; for now I choose the middle ground
849 + // and just try as many times as there are possible values.
850 + for i := 0; i < uint8Range && ctx.Err() == nil; i++ {
851 + num := uint8(weakrand.N(uint8Range)) //nolint:gosec
852 +
853 + // don't try the same number a second time
854 + if tried[num] {
855 + continue
856 + }
857 + tried[num] = true
858 +
859 + // check to see if any of the subkeys use this config ID
860 + numStr := strconv.Itoa(int(num))
861 + trialPath := path.Join(echConfigsKey, numStr)
862 + if ctx.Storage().Exists(ctx, trialPath) {
863 + continue
864 + }
865 +
866 + return num, nil
867 + }
868 +
869 + if err := ctx.Err(); err != nil {
870 + return 0, err
871 + }
872 +
873 + return 0, fmt.Errorf("depleted attempts to find an available config_id")
874 +}
875 +
876 +// svcParams represents SvcParamKey and SvcParamValue pairs as
877 +// described in https://www.rfc-editor.org/rfc/rfc9460 (section 2.1).
878 +type svcParams map[string][]string
879 +
880 +// parseSvcParams parses service parameters into a structured type
881 +// for safer manipulation.
882 +func parseSvcParams(input string) (svcParams, error) {
883 + if len(input) > 4096 {
884 + return nil, fmt.Errorf("input too long: %d", len(input))
885 + }
886 +
887 + params := make(svcParams)
888 + input = strings.TrimSpace(input) + " "
889 +
890 + for cursor := 0; cursor < len(input); cursor++ {
891 + var key, rawVal string
892 +
893 + keyValPair:
894 + for i := cursor; i < len(input); i++ {
895 + switch input[i] {
896 + case '=':
897 + key = strings.ToLower(strings.TrimSpace(input[cursor:i]))
898 + i++
899 + cursor = i
900 +
901 + var quoted bool
902 + if input[cursor] == '"' {
903 + quoted = true
904 + i++
905 + cursor = i
906 + }
907 +
908 + var escaped bool
909 +
910 + for j := cursor; j < len(input); j++ {
911 + switch input[j] {
912 + case '"':
913 + if !quoted {
914 + return nil, fmt.Errorf("illegal DQUOTE at position %d", j)
915 + }
916 + if !escaped {
917 + // end of quoted value
918 + rawVal = input[cursor:j]
919 + j++
920 + cursor = j
921 + break keyValPair
922 + }
923 + case '\\':
924 + escaped = true
925 + case ' ', '\t', '\n', '\r':
926 + if !quoted {
927 + // end of unquoted value
928 + rawVal = input[cursor:j]
929 + cursor = j
930 + break keyValPair
931 + }
932 + default:
933 + escaped = false
934 + }
935 + }
936 +
937 + case ' ', '\t', '\n', '\r':
938 + // key with no value (flag)
939 + key = input[cursor:i]
940 + params[key] = []string{}
941 + cursor = i
942 + break keyValPair
943 + }
944 + }
945 +
946 + if rawVal == "" {
947 + continue
948 + }
949 +
950 + var sb strings.Builder
951 +
952 + var escape int // start of escape sequence (after \, so 0 is never a valid start)
953 + for i := 0; i < len(rawVal); i++ {
954 + ch := rawVal[i]
955 + if escape > 0 {
956 + // validate escape sequence
957 + // (RFC 9460 Appendix A)
958 + // escaped: "\" ( non-digit / dec-octet )
959 + // non-digit: "%x21-2F / %x3A-7E"
960 + // dec-octet: "0-255 as a 3-digit decimal number"
961 + if ch >= '0' && ch <= '9' {
962 + // advance to end of decimal octet, which must be 3 digits
963 + i += 2
964 + if i > len(rawVal) {
965 + return nil, fmt.Errorf("value ends with incomplete escape sequence: %s", rawVal[escape:])
966 + }
967 + decOctet, err := strconv.Atoi(rawVal[escape : i+1])
968 + if err != nil {
969 + return nil, err
970 + }
971 + if decOctet < 0 || decOctet > 255 {
972 + return nil, fmt.Errorf("invalid decimal octet in escape sequence: %s (%d)", rawVal[escape:i], decOctet)
973 + }
974 + sb.WriteRune(rune(decOctet))
975 + escape = 0
976 + continue
977 + } else if (ch < 0x21 || ch > 0x2F) && (ch < 0x3A && ch > 0x7E) {
978 + return nil, fmt.Errorf("illegal escape sequence %s", rawVal[escape:i])
979 + }
980 + }
981 + switch ch {
982 + case ';', '(', ')':
983 + // RFC 9460 Appendix A:
984 + // > contiguous = 1*( non-special / escaped )
985 + // > non-special is VCHAR minus DQUOTE, ";", "(", ")", and "\".
986 + return nil, fmt.Errorf("illegal character in value %q at position %d: %s", rawVal, i, string(ch))
987 + case '\\':
988 + escape = i + 1
989 + default:
990 + sb.WriteByte(ch)
991 + escape = 0
992 + }
993 + }
994 +
995 + params[key] = strings.Split(sb.String(), ",")
996 + }
997 +
998 + return params, nil
999 +}
1000 +
1001 +// String serializes svcParams into zone presentation format.
1002 +func (params svcParams) String() string {
1003 + var sb strings.Builder
1004 + for key, vals := range params {
1005 + if sb.Len() > 0 {
1006 + sb.WriteRune(' ')
1007 + }
1008 + sb.WriteString(key)
1009 + var hasVal, needsQuotes bool
1010 + for _, val := range vals {
1011 + if len(val) > 0 {
1012 + hasVal = true
1013 + }
1014 + if strings.ContainsAny(val, `" `) {
1015 + needsQuotes = true
1016 + }
1017 + if hasVal && needsQuotes {
1018 + break
1019 + }
1020 + }
1021 + if hasVal {
1022 + sb.WriteRune('=')
1023 + }
1024 + if needsQuotes {
1025 + sb.WriteRune('"')
1026 + }
1027 + for i, val := range vals {
1028 + if i > 0 {
1029 + sb.WriteRune(',')
1030 + }
1031 + val = strings.ReplaceAll(val, `"`, `\"`)
1032 + val = strings.ReplaceAll(val, `,`, `\,`)
1033 + sb.WriteString(val)
1034 + }
1035 + if needsQuotes {
1036 + sb.WriteRune('"')
1037 + }
1038 + }
1039 + return sb.String()
1040 +}
1041 +
1042 +// ECHPublisher is an interface for publishing ECHConfigList values
1043 +// so that they can be used by clients.
1044 +type ECHPublisher interface {
1045 + // Returns a key that is unique to this publisher and its configuration.
1046 + // A publisher's ID combined with its config is a valid key.
1047 + // It is used to prevent duplicating publications.
1048 + PublisherKey() string
1049 +
1050 + // Publishes the ECH config list for the given innerNames. Some publishers
1051 + // may not need a list of inner/protected names, and can ignore the argument;
1052 + // most, however, will want to use it to know which inner names are to be
1053 + // associated with the given ECH config list.
1054 + PublishECHConfigList(ctx context.Context, innerNames []string, echConfigList []byte) error
1055 +}
1056 +
1057 +type echConfigMeta struct {
1058 + Created time.Time `json:"created"`
1059 + Publications publicationHistory `json:"publications"`
1060 +}
1061 +
1062 +// publicationHistory is a map of publisher key to
1063 +// map of inner name to timestamp
1064 +type publicationHistory map[string]map[string]time.Time
1065 +
1066 +// The key prefix when putting ECH configs in storage. After this
1067 +// comes the config ID.
1068 +const echConfigsKey = "ech/configs"
1069 +
1070 +// https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html
1071 +const draftTLSESNI22 = 0xfe0d
1072 +
1073 +// Interface guard
1074 +var _ ECHPublisher = (*ECHDNSPublisher)(nil)
modules/caddytls/ech_test.go new
+129
@@ -0,0 +1,129 @@
1 +package caddytls
2 +
3 +import (
4 + "reflect"
5 + "testing"
6 +)
7 +
8 +func TestParseSvcParams(t *testing.T) {
9 + for i, test := range []struct {
10 + input string
11 + expect svcParams
12 + shouldErr bool
13 + }{
14 + {
15 + input: `alpn="h2,h3" no-default-alpn ipv6hint=2001:db8::1 port=443`,
16 + expect: svcParams{
17 + "alpn": {"h2", "h3"},
18 + "no-default-alpn": {},
19 + "ipv6hint": {"2001:db8::1"},
20 + "port": {"443"},
21 + },
22 + },
23 + {
24 + input: `key=value quoted="some string" flag`,
25 + expect: svcParams{
26 + "key": {"value"},
27 + "quoted": {"some string"},
28 + "flag": {},
29 + },
30 + },
31 + {
32 + input: `key="nested \"quoted\" value,foobar"`,
33 + expect: svcParams{
34 + "key": {`nested "quoted" value`, "foobar"},
35 + },
36 + },
37 + {
38 + input: `alpn=h3,h2 tls-supported-groups=29,23 no-default-alpn ech="foobar"`,
39 + expect: svcParams{
40 + "alpn": {"h3", "h2"},
41 + "tls-supported-groups": {"29", "23"},
42 + "no-default-alpn": {},
43 + "ech": {"foobar"},
44 + },
45 + },
46 + {
47 + input: `escape=\097`,
48 + expect: svcParams{
49 + "escape": {"a"},
50 + },
51 + },
52 + {
53 + input: `escapes=\097\098c`,
54 + expect: svcParams{
55 + "escapes": {"abc"},
56 + },
57 + },
58 + } {
59 + actual, err := parseSvcParams(test.input)
60 + if err != nil && !test.shouldErr {
61 + t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test.input)
62 + continue
63 + } else if err == nil && test.shouldErr {
64 + t.Errorf("Test %d: Expected an error, but got no error (input=%q)", i, test.input)
65 + continue
66 + }
67 + if !reflect.DeepEqual(test.expect, actual) {
68 + t.Errorf("Test %d: Expected %v, got %v (input=%q)", i, test.expect, actual, test.input)
69 + continue
70 + }
71 + }
72 +}
73 +
74 +func TestSvcParamsString(t *testing.T) {
75 + // this test relies on the parser also working
76 + // because we can't just compare string outputs
77 + // since map iteration is unordered
78 + for i, test := range []svcParams{
79 +
80 + {
81 + "alpn": {"h2", "h3"},
82 + "no-default-alpn": {},
83 + "ipv6hint": {"2001:db8::1"},
84 + "port": {"443"},
85 + },
86 +
87 + {
88 + "key": {"value"},
89 + "quoted": {"some string"},
90 + "flag": {},
91 + },
92 + {
93 + "key": {`nested "quoted" value`, "foobar"},
94 + },
95 + {
96 + "alpn": {"h3", "h2"},
97 + "tls-supported-groups": {"29", "23"},
98 + "no-default-alpn": {},
99 + "ech": {"foobar"},
100 + },
101 + } {
102 + combined := test.String()
103 + parsed, err := parseSvcParams(combined)
104 + if err != nil {
105 + t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test)
106 + continue
107 + }
108 + if len(parsed) != len(test) {
109 + t.Errorf("Test %d: Expected %d keys, but got %d", i, len(test), len(parsed))
110 + continue
111 + }
112 + for key, expectedVals := range test {
113 + if expected, actual := len(expectedVals), len(parsed[key]); expected != actual {
114 + t.Errorf("Test %d: Expected key %s to have %d values, but had %d", i, key, expected, actual)
115 + continue
116 + }
117 + for j, expected := range expectedVals {
118 + if actual := parsed[key][j]; actual != expected {
119 + t.Errorf("Test %d key %q value %d: Expected '%s' but got '%s'", i, key, j, expected, actual)
120 + continue
121 + }
122 + }
123 + }
124 + if !reflect.DeepEqual(parsed, test) {
125 + t.Errorf("Test %d: Expected %#v, got %#v", i, test, combined)
126 + continue
127 + }
128 + }
129 +}
modules/caddytls/tls.go
+100 -6
@@ -20,12 +20,15 @@ import (
20 "encoding/json"
21 "fmt"
22 "log"
23 + "net"
24 "net/http"
25 "runtime/debug"
26 + "strings"
27 "sync"
28 "time"
29
30 "github.com/caddyserver/certmagic"
31 + "github.com/libdns/libdns"
32 "go.uber.org/zap"
33 "go.uber.org/zap/zapcore"
34
@@ -79,6 +82,7 @@ type TLS struct {
82 // Disabling OCSP stapling puts clients at greater risk, reduces their
83 // privacy, and usually lowers client performance. It is NOT recommended
84 // to disable this unless you are able to justify the costs.
85 + //
86 // EXPERIMENTAL. Subject to change.
87 DisableOCSPStapling bool `json:"disable_ocsp_stapling,omitempty"`
88
@@ -89,6 +93,7 @@ type TLS struct {
93 //
94 // Disabling these checks should only be done when the storage
95 // can be trusted to have enough capacity and no other problems.
96 + //
97 // EXPERIMENTAL. Subject to change.
98 DisableStorageCheck bool `json:"disable_storage_check,omitempty"`
99
@@ -100,9 +105,23 @@ type TLS struct {
105 // The instance.uuid file is used to identify the instance of Caddy
106 // in a cluster. The last_clean.json file is used to store the last
107 // time the storage was cleaned.
108 + //
109 // EXPERIMENTAL. Subject to change.
110 DisableStorageClean bool `json:"disable_storage_clean,omitempty"`
111
112 + // Enable Encrypted ClientHello (ECH). ECH protects the server name
113 + // (SNI) and other sensitive parameters of a normally-plaintext TLS
114 + // ClientHello during a handshake.
115 + //
116 + // EXPERIMENTAL: Subject to change.
117 + EncryptedClientHello *ECH `json:"encrypted_client_hello,omitempty"`
118 +
119 + // The default DNS provider module to use when a DNS module is needed.
120 + //
121 + // EXPERIMENTAL: Subject to change.
122 + DNSRaw json.RawMessage `json:"dns,omitempty" caddy:"namespace=dns.providers inline_key=name"`
123 + dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
124 +
125 certificateLoaders []CertificateLoader
126 automateNames []string
127 ctx caddy.Context
@@ -111,6 +130,9 @@ type TLS struct {
130 logger *zap.Logger
131 events *caddyevents.App
132
133 + serverNames map[string]struct{}
134 + serverNamesMu *sync.Mutex
135 +
136 // set of subjects with managed certificates,
137 // and hashes of manually-loaded certificates
138 // (managing's value is an optional issuer key, for distinction)
@@ -136,6 +158,40 @@ func (t *TLS) Provision(ctx caddy.Context) error {
158 t.logger = ctx.Logger()
159 repl := caddy.NewReplacer()
160 t.managing, t.loaded = make(map[string]string), make(map[string]string)
161 + t.serverNames = make(map[string]struct{})
162 + t.serverNamesMu = new(sync.Mutex)
163 +
164 + // set up default DNS module, if any, and make sure it implements all the
165 + // common libdns interfaces, since it could be used for a variety of things
166 + // (do this before provisioning other modules, since they may rely on this)
167 + if len(t.DNSRaw) > 0 {
168 + dnsMod, err := ctx.LoadModule(t, "DNSRaw")
169 + if err != nil {
170 + return fmt.Errorf("loading overall DNS provider module: %v", err)
171 + }
172 + switch dnsMod.(type) {
173 + case interface {
174 + libdns.RecordAppender
175 + libdns.RecordDeleter
176 + libdns.RecordGetter
177 + libdns.RecordSetter
178 + }:
179 + default:
180 + return fmt.Errorf("DNS module does not implement the most common libdns interfaces: %T", dnsMod)
181 + }
182 + t.dns = dnsMod
183 + }
184 +
185 + // ECH (Encrypted ClientHello) initialization
186 + if t.EncryptedClientHello != nil {
187 + t.EncryptedClientHello.configs = make(map[string][]echConfig)
188 + outerNames, err := t.EncryptedClientHello.Provision(ctx)
189 + if err != nil {
190 + return fmt.Errorf("provisioning Encrypted ClientHello components: %v", err)
191 + }
192 + // outer names should have certificates to reduce client brittleness
193 + t.automateNames = append(t.automateNames, outerNames...)
194 + }
195
196 // set up a new certificate cache; this (re)loads all certificates
197 cacheOpts := certmagic.CacheOptions{
@@ -178,7 +234,7 @@ func (t *TLS) Provision(ctx caddy.Context) error {
234 for i, sub := range *automateNames {
235 subjects[i] = repl.ReplaceAll(sub, "")
236 }
181 - t.automateNames = subjects
237 + t.automateNames = append(t.automateNames, subjects...)
238 } else {
239 return fmt.Errorf("loading certificates with 'automate' requires array of strings, got: %T", modIface)
240 }
@@ -339,6 +395,16 @@ func (t *TLS) Start() error {
395 return fmt.Errorf("automate: managing %v: %v", t.automateNames, err)
396 }
397
398 + // publish ECH configs in the background; does not need to block
399 + // server startup, as it could take a while
400 + if t.EncryptedClientHello != nil {
401 + go func() {
402 + if err := t.publishECHConfigs(); err != nil {
403 + t.logger.Named("ech").Error("publication(s) failed", zap.Error(err))
404 + }
405 + }()
406 + }
407 +
408 if !t.DisableStorageClean {
409 // start the storage cleaner goroutine and ticker,
410 // which cleans out expired certificates and more
@@ -422,11 +488,16 @@ func (t *TLS) Cleanup() error {
488 }
489 }
490 } else {
425 - // no more TLS app running, so delete in-memory cert cache
426 - certCache.Stop()
427 - certCacheMu.Lock()
428 - certCache = nil
429 - certCacheMu.Unlock()
491 + // no more TLS app running, so delete in-memory cert cache, if it was created yet
492 + certCacheMu.RLock()
493 + hasCache := certCache != nil
494 + certCacheMu.RUnlock()
495 + if hasCache {
496 + certCache.Stop()
497 + certCacheMu.Lock()
498 + certCache = nil
499 + certCacheMu.Unlock()
500 + }
501 }
502
503 return nil
@@ -478,6 +549,29 @@ func (t *TLS) Manage(names []string) error {
549 return nil
550 }
551
552 +// RegisterServerNames registers the provided DNS names with the TLS app.
553 +// This is currently used to auto-publish Encrypted ClientHello (ECH)
554 +// configurations, if enabled. Use of this function by apps using the TLS
555 +// app removes the need for the user to redundantly specify domain names
556 +// in their configuration. This function separates hostname and port
557 +// (keeping only the hotsname) and filters IP addresses, which can't be
558 +// used with ECH.
559 +//
560 +// EXPERIMENTAL: This function and its behavior are subject to change.
561 +func (t *TLS) RegisterServerNames(dnsNames []string) {
562 + t.serverNamesMu.Lock()
563 + for _, name := range dnsNames {
564 + host, _, err := net.SplitHostPort(name)
565 + if err != nil {
566 + host = name
567 + }
568 + if strings.TrimSpace(host) != "" && !certmagic.SubjectIsIP(host) {
569 + t.serverNames[strings.ToLower(host)] = struct{}{}
570 + }
571 + }
572 + t.serverNamesMu.Unlock()
573 +}
574 +
575 // HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP
576 // validation request is handled for the certificate named by r.Host, if it
577 // is an HTTP challenge request. It requires that the automation policy for