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)