mirror of
https://github.com/caddyserver/caddy.git
synced 2025-03-23 21:54:51 +01:00
Publishing a DNS record for a name that doesn't have any could make wildcards ineffective, which would be surprising for site owners and could lead to downtime.
1143 lines
38 KiB
Go
1143 lines
38 KiB
Go
package caddytls
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
weakrand "math/rand/v2"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/caddyserver/certmagic"
|
|
"github.com/cloudflare/circl/hpke"
|
|
"github.com/cloudflare/circl/kem"
|
|
"github.com/libdns/libdns"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/cryptobyte"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
)
|
|
|
|
func init() {
|
|
caddy.RegisterModule(ECHDNSPublisher{})
|
|
}
|
|
|
|
// ECH enables Encrypted ClientHello (ECH) and configures its management.
|
|
//
|
|
// ECH helps protect site names (also called "server names" or "domain names"
|
|
// or "SNI"), which are normally sent over plaintext when establishing a TLS
|
|
// connection. With ECH, the true ClientHello is encrypted and wrapped by an
|
|
// "outer" ClientHello that uses a more generic, shared server name that is
|
|
// publicly known.
|
|
//
|
|
// Clients need to know which public name (and other parameters) to use when
|
|
// connecting to a site with ECH, and the methods for this vary; however,
|
|
// major browsers support reading ECH configurations from DNS records (which
|
|
// is typically only secure when DNS-over-HTTPS or DNS-over-TLS is enabled in
|
|
// the client). Caddy has the ability to automatically publish ECH configs to
|
|
// DNS records if a DNS provider is configured either in the TLS app or with
|
|
// each individual publication config object. (Requires a custom build with a
|
|
// DNS provider module.)
|
|
//
|
|
// ECH requires at least TLS 1.3, so any TLS connection policies with ECH
|
|
// applied will automatically upgrade the minimum TLS version to 1.3, even if
|
|
// configured to a lower version.
|
|
//
|
|
// Note that, as of Caddy 2.10.0 (~March 2025), ECH keys are not automatically
|
|
// rotated due to a limitation in the Go standard library (see
|
|
// https://github.com/golang/go/issues/71920). This should be resolved when
|
|
// Go 1.25 is released (~Aug. 2025), and Caddy will be updated to automatically
|
|
// rotate ECH keys/configs at that point.
|
|
//
|
|
// EXPERIMENTAL: Subject to change.
|
|
type ECH struct {
|
|
// The list of ECH configurations for which to automatically generate
|
|
// and rotate keys. At least one is required to enable ECH.
|
|
//
|
|
// It is strongly recommended to use as few ECH configs as possible
|
|
// to maximize the size of your anonymity set (see the ECH specification
|
|
// for a definition). Typically, each server should have only one public
|
|
// name, i.e. one config in this list.
|
|
Configs []ECHConfiguration `json:"configs,omitempty"`
|
|
|
|
// Publication describes ways to publish ECH configs for clients to
|
|
// discover and use. Without publication, most clients will not use
|
|
// ECH at all, and those that do will suffer degraded performance.
|
|
//
|
|
// Most major browsers support ECH by way of publication to HTTPS
|
|
// DNS RRs. (This also typically requires that they use DoH or DoT.)
|
|
Publication []*ECHPublication `json:"publication,omitempty"`
|
|
|
|
// map of public_name to list of configs
|
|
configs map[string][]echConfig
|
|
}
|
|
|
|
// Provision loads or creates ECH configs and returns outer names (for certificate
|
|
// management), but does not publish any ECH configs. The DNS module is used as
|
|
// a default for later publishing if needed.
|
|
func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) {
|
|
logger := ctx.Logger().Named("ech")
|
|
|
|
// set up publication modules before we need to obtain a lock in storage,
|
|
// since this is strictly internal and doesn't require synchronization
|
|
for i, pub := range ech.Publication {
|
|
mods, err := ctx.LoadModule(pub, "PublishersRaw")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading ECH publication modules: %v", err)
|
|
}
|
|
for _, modIface := range mods.(map[string]any) {
|
|
ech.Publication[i].publishers = append(ech.Publication[i].publishers, modIface.(ECHPublisher))
|
|
}
|
|
}
|
|
|
|
// the rest of provisioning needs an exclusive lock so that instances aren't
|
|
// stepping on each other when setting up ECH configs
|
|
storage := ctx.Storage()
|
|
const echLockName = "ech_provision"
|
|
if err := storage.Lock(ctx, echLockName); err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := storage.Unlock(ctx, echLockName); err != nil {
|
|
logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
|
|
}
|
|
}()
|
|
|
|
var outerNames []string //nolint:prealloc // (FALSE POSITIVE - see https://github.com/alexkohler/prealloc/issues/30)
|
|
|
|
// start by loading all the existing configs (even the older ones on the way out,
|
|
// since some clients may still be using them if they haven't yet picked up on the
|
|
// new configs)
|
|
cfgKeys, err := storage.List(ctx, echConfigsKey, false)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) { // OK if dir doesn't exist; it will be created
|
|
return nil, err
|
|
}
|
|
for _, cfgKey := range cfgKeys {
|
|
cfg, err := loadECHConfig(ctx, path.Base(cfgKey))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// if any part of the config's folder was corrupted, the load function will
|
|
// clean it up and not return an error, since configs are immutable and
|
|
// fairly ephemeral... so just check that we actually got a populated config
|
|
if cfg.configBin == nil || cfg.privKeyBin == nil {
|
|
continue
|
|
}
|
|
logger.Debug("loaded ECH config",
|
|
zap.String("public_name", cfg.RawPublicName),
|
|
zap.Uint8("id", cfg.ConfigID))
|
|
ech.configs[cfg.RawPublicName] = append(ech.configs[cfg.RawPublicName], cfg)
|
|
outerNames = append(outerNames, cfg.RawPublicName)
|
|
}
|
|
|
|
// all existing configs are now loaded; see if we need to make any new ones
|
|
// based on the input configuration, and also mark the most recent one(s) as
|
|
// current/active, so they can be used for ECH retries
|
|
|
|
for _, cfg := range ech.Configs {
|
|
publicName := strings.ToLower(strings.TrimSpace(cfg.PublicName))
|
|
|
|
if list, ok := ech.configs[publicName]; ok && len(list) > 0 {
|
|
// at least one config with this public name was loaded, so find the
|
|
// most recent one and mark it as active to be used with retries
|
|
var mostRecentDate time.Time
|
|
var mostRecentIdx int
|
|
for i, c := range list {
|
|
if mostRecentDate.IsZero() || c.meta.Created.After(mostRecentDate) {
|
|
mostRecentDate = c.meta.Created
|
|
mostRecentIdx = i
|
|
}
|
|
}
|
|
list[mostRecentIdx].sendAsRetry = true
|
|
} else {
|
|
// no config with this public name was loaded, so create one
|
|
echCfg, err := generateAndStoreECHConfig(ctx, publicName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger.Debug("generated new ECH config",
|
|
zap.String("public_name", echCfg.RawPublicName),
|
|
zap.Uint8("id", echCfg.ConfigID))
|
|
ech.configs[publicName] = append(ech.configs[publicName], echCfg)
|
|
outerNames = append(outerNames, publicName)
|
|
}
|
|
}
|
|
|
|
return outerNames, nil
|
|
}
|
|
|
|
func (t *TLS) publishECHConfigs() error {
|
|
logger := t.logger.Named("ech")
|
|
|
|
// make publication exclusive, since we don't need to repeat this unnecessarily
|
|
storage := t.ctx.Storage()
|
|
const echLockName = "ech_publish"
|
|
if err := storage.Lock(t.ctx, echLockName); err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := storage.Unlock(t.ctx, echLockName); err != nil {
|
|
logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
|
|
}
|
|
}()
|
|
|
|
// get the publication config, or use a default if not specified
|
|
// (the default publication config should be to publish all ECH
|
|
// configs to the app-global DNS provider; if no DNS provider is
|
|
// configured, then this whole function is basically a no-op)
|
|
publicationList := t.EncryptedClientHello.Publication
|
|
if publicationList == nil {
|
|
if dnsProv, ok := t.dns.(ECHDNSProvider); ok {
|
|
publicationList = []*ECHPublication{
|
|
{
|
|
publishers: []ECHPublisher{
|
|
&ECHDNSPublisher{
|
|
provider: dnsProv,
|
|
logger: t.logger,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// for each publication config, build the list of ECH configs to
|
|
// publish with it, and figure out which inner names to publish
|
|
// to/for, then publish
|
|
for _, publication := range publicationList {
|
|
// this publication is either configured for specific ECH configs,
|
|
// or we just use an implied default of all ECH configs
|
|
var echCfgList echConfigList
|
|
var configIDs []uint8 // TODO: use IDs or the outer names?
|
|
if publication.Configs == nil {
|
|
// by default, publish all configs
|
|
for _, configs := range t.EncryptedClientHello.configs {
|
|
echCfgList = append(echCfgList, configs...)
|
|
for _, c := range configs {
|
|
configIDs = append(configIDs, c.ConfigID)
|
|
}
|
|
}
|
|
} else {
|
|
for _, cfgOuterName := range publication.Configs {
|
|
if cfgList, ok := t.EncryptedClientHello.configs[cfgOuterName]; ok {
|
|
echCfgList = append(echCfgList, cfgList...)
|
|
for _, c := range cfgList {
|
|
configIDs = append(configIDs, c.ConfigID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// marshal the ECH config list as binary for publication
|
|
echCfgListBin, err := echCfgList.MarshalBinary()
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling ECH config list: %v", err)
|
|
}
|
|
|
|
// now we have our list of ECH configs to publish and the inner names
|
|
// to publish for (i.e. the names being protected); iterate each publisher
|
|
// and do the publish for any config+name that needs a publish
|
|
for _, publisher := range publication.publishers {
|
|
publisherKey := publisher.PublisherKey()
|
|
|
|
// by default, publish for all (non-outer) server names, unless
|
|
// a specific list of names is configured
|
|
var serverNamesSet map[string]struct{}
|
|
if publication.Domains == nil {
|
|
serverNamesSet = make(map[string]struct{}, len(t.serverNames))
|
|
for name := range t.serverNames {
|
|
serverNamesSet[name] = struct{}{}
|
|
}
|
|
} else {
|
|
serverNamesSet = make(map[string]struct{}, len(publication.Domains))
|
|
for _, name := range publication.Domains {
|
|
serverNamesSet[name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// remove any domains from the set which have already had all configs in the
|
|
// list published by this publisher, to avoid always re-publishing unnecessarily
|
|
for configuredInnerName := range serverNamesSet {
|
|
allConfigsPublished := true
|
|
for _, cfg := range echCfgList {
|
|
// TODO: Potentially utilize the timestamp (map value) for recent-enough publication, instead of just checking for existence
|
|
if _, ok := cfg.meta.Publications[publisherKey][configuredInnerName]; !ok {
|
|
allConfigsPublished = false
|
|
break
|
|
}
|
|
}
|
|
if allConfigsPublished {
|
|
delete(serverNamesSet, configuredInnerName)
|
|
}
|
|
}
|
|
|
|
// if all the (inner) domains have had this ECH config list published
|
|
// by this publisher, then try the next publication config
|
|
if len(serverNamesSet) == 0 {
|
|
logger.Debug("ECH config list already published by publisher for associated domains",
|
|
zap.Uint8s("config_ids", configIDs),
|
|
zap.String("publisher", publisherKey))
|
|
continue
|
|
}
|
|
|
|
// convert the set of names to a slice
|
|
dnsNamesToPublish := make([]string, 0, len(serverNamesSet))
|
|
for name := range serverNamesSet {
|
|
dnsNamesToPublish = append(dnsNamesToPublish, name)
|
|
}
|
|
|
|
logger.Debug("publishing ECH config list",
|
|
zap.Strings("domains", dnsNamesToPublish),
|
|
zap.Uint8s("config_ids", configIDs))
|
|
|
|
// publish this ECH config list with this publisher
|
|
pubTime := time.Now()
|
|
err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin)
|
|
if err == nil {
|
|
t.logger.Info("published ECH configuration list",
|
|
zap.Strings("domains", publication.Domains),
|
|
zap.Uint8s("config_ids", configIDs),
|
|
zap.Error(err))
|
|
// update publication history, so that we don't unnecessarily republish every time
|
|
for _, cfg := range echCfgList {
|
|
if cfg.meta.Publications == nil {
|
|
cfg.meta.Publications = make(publicationHistory)
|
|
}
|
|
if _, ok := cfg.meta.Publications[publisherKey]; !ok {
|
|
cfg.meta.Publications[publisherKey] = make(map[string]time.Time)
|
|
}
|
|
for _, name := range dnsNamesToPublish {
|
|
cfg.meta.Publications[publisherKey][name] = pubTime
|
|
}
|
|
metaBytes, err := json.Marshal(cfg.meta)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling ECH config metadata: %v", err)
|
|
}
|
|
metaKey := path.Join(echConfigsKey, strconv.Itoa(int(cfg.ConfigID)), "meta.json")
|
|
if err := t.ctx.Storage().Store(t.ctx, metaKey, metaBytes); err != nil {
|
|
return fmt.Errorf("storing updated ECH config metadata: %v", err)
|
|
}
|
|
}
|
|
} else {
|
|
t.logger.Error("publishing ECH configuration list",
|
|
zap.Strings("domains", publication.Domains),
|
|
zap.Uint8s("config_ids", configIDs),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadECHConfig loads the config from storage with the given configID.
|
|
// An error is not actually returned in some cases the config fails to
|
|
// load because in some cases it just means the config ID folder has
|
|
// been cleaned up in storage, maybe due to an incomplete set of keys
|
|
// or corrupted contents; in any case, the only rectification is to
|
|
// delete it and make new keys (an error IS returned if deleting the
|
|
// corrupted keys fails, for example). Check the returned echConfig for
|
|
// non-nil privKeyBin and configBin values before using.
|
|
func loadECHConfig(ctx caddy.Context, configID string) (echConfig, error) {
|
|
storage := ctx.Storage()
|
|
logger := ctx.Logger()
|
|
|
|
cfgIDKey := path.Join(echConfigsKey, configID)
|
|
keyKey := path.Join(cfgIDKey, "key.bin")
|
|
configKey := path.Join(cfgIDKey, "config.bin")
|
|
metaKey := path.Join(cfgIDKey, "meta.json")
|
|
|
|
// if loading anything fails, might as well delete this folder and free up
|
|
// the config ID; spec is designed to rotate configs frequently anyway
|
|
// (I consider it a more serious error if we can't clean up the folder,
|
|
// since leaving stray storage keys is confusing)
|
|
privKeyBytes, err := storage.Load(ctx, keyKey)
|
|
if err != nil {
|
|
delErr := storage.Delete(ctx, cfgIDKey)
|
|
if delErr != nil {
|
|
return echConfig{}, fmt.Errorf("error loading private key (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
|
|
}
|
|
logger.Warn("could not load ECH private key; deleting its config folder",
|
|
zap.String("config_id", configID),
|
|
zap.Error(err))
|
|
return echConfig{}, nil
|
|
}
|
|
echConfigBytes, err := storage.Load(ctx, configKey)
|
|
if err != nil {
|
|
delErr := storage.Delete(ctx, cfgIDKey)
|
|
if delErr != nil {
|
|
return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
|
|
}
|
|
logger.Warn("could not load ECH config; deleting its config folder",
|
|
zap.String("config_id", configID),
|
|
zap.Error(err))
|
|
return echConfig{}, nil
|
|
}
|
|
var cfg echConfig
|
|
if err := cfg.UnmarshalBinary(echConfigBytes); err != nil {
|
|
delErr := storage.Delete(ctx, cfgIDKey)
|
|
if delErr != nil {
|
|
return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
|
|
}
|
|
logger.Warn("could not load ECH config; deleted its config folder",
|
|
zap.String("config_id", configID),
|
|
zap.Error(err))
|
|
return echConfig{}, nil
|
|
}
|
|
metaBytes, err := storage.Load(ctx, metaKey)
|
|
if err != nil {
|
|
delErr := storage.Delete(ctx, cfgIDKey)
|
|
if delErr != nil {
|
|
return echConfig{}, fmt.Errorf("error loading ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
|
|
}
|
|
logger.Warn("could not load ECH metadata; deleted its config folder",
|
|
zap.String("config_id", configID),
|
|
zap.Error(err))
|
|
return echConfig{}, nil
|
|
}
|
|
var meta echConfigMeta
|
|
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
|
// even though it's just metadata, reset the whole config since we can't reliably maintain it
|
|
delErr := storage.Delete(ctx, cfgIDKey)
|
|
if delErr != nil {
|
|
return echConfig{}, fmt.Errorf("error decoding ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
|
|
}
|
|
logger.Warn("could not JSON-decode ECH metadata; deleted its config folder",
|
|
zap.String("config_id", configID),
|
|
zap.Error(err))
|
|
return echConfig{}, nil
|
|
}
|
|
|
|
cfg.privKeyBin = privKeyBytes
|
|
cfg.configBin = echConfigBytes
|
|
cfg.meta = meta
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func generateAndStoreECHConfig(ctx caddy.Context, publicName string) (echConfig, error) {
|
|
// Go currently has very strict requirements for server-side ECH configs,
|
|
// to quote the Go 1.24 godoc (with typos of AEAD IDs corrected):
|
|
//
|
|
// "Config should be a marshalled ECHConfig associated with PrivateKey. This
|
|
// must match the config provided to clients byte-for-byte. The config
|
|
// should only specify the DHKEM(X25519, HKDF-SHA256) KEM ID (0x0020), the
|
|
// HKDF-SHA256 KDF ID (0x0001), and a subset of the following AEAD IDs:
|
|
// AES-128-GCM (0x0001), AES-256-GCM (0x0002), ChaCha20Poly1305 (0x0003)."
|
|
//
|
|
// So we need to be sure we generate a config within these parameters
|
|
// so the Go TLS server can use it.
|
|
|
|
// generate a key pair
|
|
const kemChoice = hpke.KEM_X25519_HKDF_SHA256
|
|
publicKey, privateKey, err := kemChoice.Scheme().GenerateKeyPair()
|
|
if err != nil {
|
|
return echConfig{}, err
|
|
}
|
|
|
|
// find an available config ID
|
|
configID, err := newECHConfigID(ctx)
|
|
if err != nil {
|
|
return echConfig{}, fmt.Errorf("generating unique config ID: %v", err)
|
|
}
|
|
|
|
echCfg := echConfig{
|
|
PublicKey: publicKey,
|
|
Version: draftTLSESNI22,
|
|
ConfigID: configID,
|
|
RawPublicName: publicName,
|
|
KEMID: kemChoice,
|
|
CipherSuites: []hpkeSymmetricCipherSuite{
|
|
{
|
|
KDFID: hpke.KDF_HKDF_SHA256,
|
|
AEADID: hpke.AEAD_AES128GCM,
|
|
},
|
|
{
|
|
KDFID: hpke.KDF_HKDF_SHA256,
|
|
AEADID: hpke.AEAD_AES256GCM,
|
|
},
|
|
{
|
|
KDFID: hpke.KDF_HKDF_SHA256,
|
|
AEADID: hpke.AEAD_ChaCha20Poly1305,
|
|
},
|
|
},
|
|
sendAsRetry: true,
|
|
}
|
|
meta := echConfigMeta{
|
|
Created: time.Now(),
|
|
}
|
|
|
|
privKeyBytes, err := privateKey.MarshalBinary()
|
|
if err != nil {
|
|
return echConfig{}, fmt.Errorf("marshaling ECH private key: %v", err)
|
|
}
|
|
echConfigBytes, err := echCfg.MarshalBinary()
|
|
if err != nil {
|
|
return echConfig{}, fmt.Errorf("marshaling ECH config: %v", err)
|
|
}
|
|
metaBytes, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return echConfig{}, fmt.Errorf("marshaling ECH config metadata: %v", err)
|
|
}
|
|
|
|
parentKey := path.Join(echConfigsKey, strconv.Itoa(int(configID)))
|
|
keyKey := path.Join(parentKey, "key.bin")
|
|
configKey := path.Join(parentKey, "config.bin")
|
|
metaKey := path.Join(parentKey, "meta.json")
|
|
|
|
if err := ctx.Storage().Store(ctx, keyKey, privKeyBytes); err != nil {
|
|
return echConfig{}, fmt.Errorf("storing ECH private key: %v", err)
|
|
}
|
|
if err := ctx.Storage().Store(ctx, configKey, echConfigBytes); err != nil {
|
|
return echConfig{}, fmt.Errorf("storing ECH config: %v", err)
|
|
}
|
|
if err := ctx.Storage().Store(ctx, metaKey, metaBytes); err != nil {
|
|
return echConfig{}, fmt.Errorf("storing ECH config metadata: %v", err)
|
|
}
|
|
|
|
echCfg.privKeyBin = privKeyBytes
|
|
echCfg.configBin = echConfigBytes // this contains the public key
|
|
echCfg.meta = meta
|
|
|
|
return echCfg, nil
|
|
}
|
|
|
|
// ECH represents an Encrypted ClientHello configuration.
|
|
//
|
|
// EXPERIMENTAL: Subject to change.
|
|
type ECHConfiguration struct {
|
|
// The public server name (SNI) that will be used in the outer ClientHello.
|
|
// This should be a domain name for which this server is authoritative,
|
|
// because Caddy will try to provision a certificate for this name. As an
|
|
// outer SNI, it is never used for application data (HTTPS, etc.), but it
|
|
// is necessary for enabling clients to connect securely in some cases.
|
|
// If this field is empty or missing, or if Caddy cannot get a certificate
|
|
// for this domain (e.g. the domain's DNS records do not point to this server),
|
|
// client reliability becomes brittle, and you risk coercing clients to expose
|
|
// true server names in plaintext, which compromises both the privacy of the
|
|
// server and makes clients more vulnerable.
|
|
PublicName string `json:"public_name"`
|
|
}
|
|
|
|
// ECHPublication configures publication of ECH config(s). It pairs a list
|
|
// of ECH configs with the list of domains they are assigned to protect, and
|
|
// describes how to publish those configs for those domains.
|
|
//
|
|
// Most servers will have only a single publication config, unless their
|
|
// domains are spread across multiple DNS providers or require different
|
|
// methods of publication.
|
|
//
|
|
// EXPERIMENTAL: Subject to change.
|
|
type ECHPublication struct {
|
|
// The list of ECH configurations to publish, identified by public name.
|
|
// If not set, all configs will be included for publication by default.
|
|
//
|
|
// It is generally advised to maximize the size of your anonymity set,
|
|
// which implies using as few public names as possible for your sites.
|
|
// Usually, only a single public name is used to protect all the sites
|
|
// for a server
|
|
//
|
|
// EXPERIMENTAL: This field may be renamed or have its structure changed.
|
|
Configs []string `json:"configs,omitempty"`
|
|
|
|
// The list of ("inner") domain names which are protected with the associated
|
|
// ECH configurations.
|
|
//
|
|
// If not set, all server names registered with the TLS module will be
|
|
// added to this list implicitly. (This registration is done automatically
|
|
// by other Caddy apps that use the TLS module. They should register their
|
|
// configured server names for this purpose. For example, the HTTP server
|
|
// registers the hostnames for which it applies automatic HTTPS. This is
|
|
// not something you, the user, have to do.) Most servers
|
|
//
|
|
// Names in this list should not appear in any other publication config
|
|
// object with the same publishers, since the publications will likely
|
|
// overwrite each other.
|
|
//
|
|
// NOTE: In order to publish ECH configs for domains configured for
|
|
// On-Demand TLS that are not explicitly enumerated elsewhere in the
|
|
// config, those domain names will have to be listed here. The only
|
|
// time Caddy knows which domains it is serving with On-Demand TLS is
|
|
// handshake-time, which is too late for publishing ECH configs; it
|
|
// means the first connections would not protect the server names,
|
|
// revealing that information to observers, and thus defeating the
|
|
// purpose of ECH. Hence the need to list them here so Caddy can
|
|
// proactively publish ECH configs before clients connect with those
|
|
// server names in plaintext.
|
|
Domains []string `json:"domains,omitempty"`
|
|
|
|
// How to publish the ECH configurations so clients can know to use
|
|
// ECH to connect more securely to the server.
|
|
PublishersRaw caddy.ModuleMap `json:"publishers,omitempty" caddy:"namespace=tls.ech.publishers"`
|
|
publishers []ECHPublisher
|
|
}
|
|
|
|
// ECHDNSProvider can service DNS entries for ECH purposes.
|
|
type ECHDNSProvider interface {
|
|
libdns.RecordGetter
|
|
libdns.RecordSetter
|
|
}
|
|
|
|
// ECHDNSPublisher configures how to publish an ECH configuration to
|
|
// DNS records for the specified domains.
|
|
//
|
|
// EXPERIMENTAL: Subject to change.
|
|
type ECHDNSPublisher struct {
|
|
// The DNS provider module which will establish the HTTPS record(s).
|
|
ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"`
|
|
provider ECHDNSProvider
|
|
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// CaddyModule returns the Caddy module information.
|
|
func (ECHDNSPublisher) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "tls.ech.publishers.dns",
|
|
New: func() caddy.Module { return new(ECHDNSPublisher) },
|
|
}
|
|
}
|
|
|
|
func (dnsPub *ECHDNSPublisher) Provision(ctx caddy.Context) error {
|
|
dnsProvMod, err := ctx.LoadModule(dnsPub, "ProviderRaw")
|
|
if err != nil {
|
|
return fmt.Errorf("loading ECH DNS provider module: %v", err)
|
|
}
|
|
prov, ok := dnsProvMod.(ECHDNSProvider)
|
|
if !ok {
|
|
return fmt.Errorf("ECH DNS provider module is not an ECH DNS Provider: %v", err)
|
|
}
|
|
dnsPub.provider = prov
|
|
dnsPub.logger = ctx.Logger()
|
|
return nil
|
|
}
|
|
|
|
// PublisherKey returns the name of the DNS provider module.
|
|
// We intentionally omit specific provider configuration (or a hash thereof,
|
|
// since the config is likely sensitive, potentially containing an API key)
|
|
// because it is unlikely that specific configuration, such as an API key,
|
|
// is relevant to unique key use as an ECH config publisher.
|
|
func (dnsPub ECHDNSPublisher) PublisherKey() string {
|
|
return string(dnsPub.provider.(caddy.Module).CaddyModule().ID)
|
|
}
|
|
|
|
// PublishECHConfigList publishes the given ECH config list to the given DNS names.
|
|
func (dnsPub *ECHDNSPublisher) PublishECHConfigList(ctx context.Context, innerNames []string, configListBin []byte) error {
|
|
nameservers := certmagic.RecursiveNameservers(nil) // TODO: we could make resolvers configurable
|
|
|
|
for _, domain := range innerNames {
|
|
zone, err := certmagic.FindZoneByFQDN(ctx, dnsPub.logger, domain, nameservers)
|
|
if err != nil {
|
|
dnsPub.logger.Error("could not determine zone for domain",
|
|
zap.String("domain", domain),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
relName := libdns.RelativeName(domain+".", zone)
|
|
// TODO: libdns.RelativeName should probably return "@" instead of "". (The latest commits of libdns do this, so remove this logic once upgraded.)
|
|
if relName == "" {
|
|
relName = "@"
|
|
}
|
|
|
|
// get existing records for this domain; we need to make sure another
|
|
// record exists for it so we don't accidentally trample a wildcard; we
|
|
// also want to get any HTTPS record that may already exist for it so
|
|
// we can augment the ech SvcParamKey with any other existing SvcParams
|
|
recs, err := dnsPub.provider.GetRecords(ctx, zone)
|
|
if err != nil {
|
|
dnsPub.logger.Error("unable to get existing DNS records to publish ECH data to HTTPS DNS record",
|
|
zap.String("domain", domain),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
var httpsRec libdns.Record
|
|
var nameHasExistingRecord bool
|
|
for _, rec := range recs {
|
|
if rec.Name == relName {
|
|
nameHasExistingRecord = true
|
|
if rec.Type == "HTTPS" && (rec.Target == "" || rec.Target == ".") {
|
|
httpsRec = rec
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !nameHasExistingRecord {
|
|
// Turns out if you publish a DNS record for a name that doesn't have any DNS record yet,
|
|
// any wildcard records won't apply for the name anymore, meaning if a wildcard A/AAAA record
|
|
// is used to resolve the domain to a server, publishing an HTTPS record could break resolution!
|
|
// In theory, this should be a non-issue, at least for A/AAAA records, if the HTTPS record
|
|
// includes ipv[4|6]hint SvcParamKeys,
|
|
dnsPub.logger.Warn("domain does not have any existing records, so skipping publication of HTTPS record",
|
|
zap.String("domain", domain),
|
|
zap.String("relative_name", relName),
|
|
zap.String("zone", zone))
|
|
continue
|
|
}
|
|
params := make(svcParams)
|
|
if httpsRec.Value != "" {
|
|
params, err = parseSvcParams(httpsRec.Value)
|
|
if err != nil {
|
|
dnsPub.logger.Error("unable to parse existing DNS record to publish ECH data to HTTPS DNS record",
|
|
zap.String("domain", domain),
|
|
zap.String("https_rec_value", httpsRec.Value),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
// overwrite only the ech SvcParamKey
|
|
params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
|
|
|
|
// publish record
|
|
_, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{
|
|
{
|
|
// HTTPS and SVCB RRs: RFC 9460 (https://www.rfc-editor.org/rfc/rfc9460)
|
|
Type: "HTTPS",
|
|
Name: relName,
|
|
Priority: 2, // allows a manual override with priority 1
|
|
Target: ".",
|
|
Value: params.String(),
|
|
TTL: 1 * time.Minute, // TODO: for testing only
|
|
},
|
|
})
|
|
if err != nil {
|
|
// TODO: Maybe this should just stop and return the error...
|
|
dnsPub.logger.Error("unable to publish ECH data to HTTPS DNS record",
|
|
zap.String("domain", domain),
|
|
zap.String("zone", zone),
|
|
zap.String("dns_record_name", relName),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// echConfig represents an ECHConfig from the specification,
|
|
// [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html).
|
|
type echConfig struct {
|
|
// "The version of ECH for which this configuration is used.
|
|
// The version is the same as the code point for the
|
|
// encrypted_client_hello extension. Clients MUST ignore any
|
|
// ECHConfig structure with a version they do not support."
|
|
Version uint16
|
|
|
|
// The "length" and "contents" fields defined next in the
|
|
// structure are implicitly taken care of by cryptobyte
|
|
// when encoding the following fields:
|
|
|
|
// HpkeKeyConfig fields:
|
|
ConfigID uint8
|
|
KEMID hpke.KEM
|
|
PublicKey kem.PublicKey
|
|
CipherSuites []hpkeSymmetricCipherSuite
|
|
|
|
// ECHConfigContents fields:
|
|
MaxNameLength uint8
|
|
RawPublicName string
|
|
RawExtensions []byte
|
|
|
|
// these fields are not part of the spec, but are here for
|
|
// our use when setting up TLS servers or maintenance
|
|
configBin []byte
|
|
privKeyBin []byte
|
|
meta echConfigMeta
|
|
sendAsRetry bool
|
|
}
|
|
|
|
func (echCfg echConfig) MarshalBinary() ([]byte, error) {
|
|
var b cryptobyte.Builder
|
|
if err := echCfg.marshalBinary(&b); err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
// UnmarshalBinary decodes the data back into an ECH config.
|
|
//
|
|
// Borrowed from github.com/OmarTariq612/goech with modifications.
|
|
// Original code: Copyright (c) 2023 Omar Tariq AbdEl-Raziq
|
|
func (echCfg *echConfig) UnmarshalBinary(data []byte) error {
|
|
var content cryptobyte.String
|
|
b := cryptobyte.String(data)
|
|
|
|
if !b.ReadUint16(&echCfg.Version) {
|
|
return errInvalidLen
|
|
}
|
|
if echCfg.Version != draftTLSESNI22 {
|
|
return fmt.Errorf("supported version must be %d: got %d", draftTLSESNI22, echCfg.Version)
|
|
}
|
|
|
|
if !b.ReadUint16LengthPrefixed(&content) || !b.Empty() {
|
|
return errInvalidLen
|
|
}
|
|
|
|
var t cryptobyte.String
|
|
var pk []byte
|
|
|
|
if !content.ReadUint8(&echCfg.ConfigID) ||
|
|
!content.ReadUint16((*uint16)(&echCfg.KEMID)) ||
|
|
!content.ReadUint16LengthPrefixed(&t) ||
|
|
!t.ReadBytes(&pk, len(t)) ||
|
|
!content.ReadUint16LengthPrefixed(&t) ||
|
|
len(t)%4 != 0 /* the length of (KDFs and AEADs) must be divisible by 4 */ {
|
|
return errInvalidLen
|
|
}
|
|
|
|
if !echCfg.KEMID.IsValid() {
|
|
return fmt.Errorf("invalid KEM ID: %d", echCfg.KEMID)
|
|
}
|
|
|
|
var err error
|
|
if echCfg.PublicKey, err = echCfg.KEMID.Scheme().UnmarshalBinaryPublicKey(pk); err != nil {
|
|
return fmt.Errorf("parsing public_key: %w", err)
|
|
}
|
|
|
|
echCfg.CipherSuites = echCfg.CipherSuites[:0]
|
|
|
|
for !t.Empty() {
|
|
var hpkeKDF, hpkeAEAD uint16
|
|
if !t.ReadUint16(&hpkeKDF) || !t.ReadUint16(&hpkeAEAD) {
|
|
// we have already checked that the length is divisible by 4
|
|
panic("this must not happen")
|
|
}
|
|
if !hpke.KDF(hpkeKDF).IsValid() {
|
|
return fmt.Errorf("invalid KDF ID: %d", hpkeKDF)
|
|
}
|
|
if !hpke.AEAD(hpkeAEAD).IsValid() {
|
|
return fmt.Errorf("invalid AEAD ID: %d", hpkeAEAD)
|
|
}
|
|
echCfg.CipherSuites = append(echCfg.CipherSuites, hpkeSymmetricCipherSuite{
|
|
KDFID: hpke.KDF(hpkeKDF),
|
|
AEADID: hpke.AEAD(hpkeAEAD),
|
|
})
|
|
}
|
|
|
|
var rawPublicName []byte
|
|
if !content.ReadUint8(&echCfg.MaxNameLength) ||
|
|
!content.ReadUint8LengthPrefixed(&t) ||
|
|
!t.ReadBytes(&rawPublicName, len(t)) ||
|
|
!content.ReadUint16LengthPrefixed(&t) ||
|
|
!t.ReadBytes(&echCfg.RawExtensions, len(t)) ||
|
|
!content.Empty() {
|
|
return errInvalidLen
|
|
}
|
|
echCfg.RawPublicName = string(rawPublicName)
|
|
|
|
return nil
|
|
}
|
|
|
|
var errInvalidLen = errors.New("invalid length")
|
|
|
|
// marshalBinary writes this config to the cryptobyte builder. If there is an error,
|
|
// it will occur before any writes have happened.
|
|
func (echCfg echConfig) marshalBinary(b *cryptobyte.Builder) error {
|
|
pk, err := echCfg.PublicKey.MarshalBinary()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if l := len(echCfg.RawPublicName); l == 0 || l > 255 {
|
|
return fmt.Errorf("public name length (%d) must be in the range 1-255", l)
|
|
}
|
|
|
|
b.AddUint16(echCfg.Version)
|
|
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { // "length" field
|
|
b.AddUint8(echCfg.ConfigID)
|
|
b.AddUint16(uint16(echCfg.KEMID))
|
|
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
|
|
b.AddBytes(pk)
|
|
})
|
|
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
|
|
for _, cs := range echCfg.CipherSuites {
|
|
b.AddUint16(uint16(cs.KDFID))
|
|
b.AddUint16(uint16(cs.AEADID))
|
|
}
|
|
})
|
|
b.AddUint8(uint8(min(len(echCfg.RawPublicName)+16, 255)))
|
|
b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
|
|
b.AddBytes([]byte(echCfg.RawPublicName))
|
|
})
|
|
b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
|
|
child.AddBytes(echCfg.RawExtensions)
|
|
})
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
type hpkeSymmetricCipherSuite struct {
|
|
KDFID hpke.KDF
|
|
AEADID hpke.AEAD
|
|
}
|
|
|
|
type echConfigList []echConfig
|
|
|
|
func (cl echConfigList) MarshalBinary() ([]byte, error) {
|
|
var b cryptobyte.Builder
|
|
var err error
|
|
|
|
// the list's length prefixes the list, as with most opaque values
|
|
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
|
|
for _, cfg := range cl {
|
|
if err = cfg.marshalBinary(b); err != nil {
|
|
break
|
|
}
|
|
}
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return b.Bytes()
|
|
}
|
|
|
|
func newECHConfigID(ctx caddy.Context) (uint8, error) {
|
|
// uint8 can be 0-255 inclusive
|
|
const uint8Range = 256
|
|
|
|
// avoid repeating storage checks
|
|
tried := make([]bool, uint8Range)
|
|
|
|
// Try to find an available number with random rejection sampling;
|
|
// i.e. choose a random number and see if it's already taken.
|
|
// The hard limit on how many times we try to find an available
|
|
// number is flexible... in theory, assuming uniform distribution,
|
|
// 256 attempts should make each possible value show up exactly
|
|
// once, but obviously that won't be the case. We can try more
|
|
// times to try to ensure that every number gets a chance, which
|
|
// is especially useful if few are available, or we can lower it
|
|
// if we assume we should have found an available value by then
|
|
// and want to limit runtime; for now I choose the middle ground
|
|
// and just try as many times as there are possible values.
|
|
for i := 0; i < uint8Range && ctx.Err() == nil; i++ {
|
|
num := uint8(weakrand.N(uint8Range)) //nolint:gosec
|
|
|
|
// don't try the same number a second time
|
|
if tried[num] {
|
|
continue
|
|
}
|
|
tried[num] = true
|
|
|
|
// check to see if any of the subkeys use this config ID
|
|
numStr := strconv.Itoa(int(num))
|
|
trialPath := path.Join(echConfigsKey, numStr)
|
|
if ctx.Storage().Exists(ctx, trialPath) {
|
|
continue
|
|
}
|
|
|
|
return num, nil
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return 0, fmt.Errorf("depleted attempts to find an available config_id")
|
|
}
|
|
|
|
// svcParams represents SvcParamKey and SvcParamValue pairs as
|
|
// described in https://www.rfc-editor.org/rfc/rfc9460 (section 2.1).
|
|
type svcParams map[string][]string
|
|
|
|
// parseSvcParams parses service parameters into a structured type
|
|
// for safer manipulation.
|
|
func parseSvcParams(input string) (svcParams, error) {
|
|
if len(input) > 4096 {
|
|
return nil, fmt.Errorf("input too long: %d", len(input))
|
|
}
|
|
|
|
params := make(svcParams)
|
|
input = strings.TrimSpace(input) + " "
|
|
|
|
for cursor := 0; cursor < len(input); cursor++ {
|
|
var key, rawVal string
|
|
|
|
keyValPair:
|
|
for i := cursor; i < len(input); i++ {
|
|
switch input[i] {
|
|
case '=':
|
|
key = strings.ToLower(strings.TrimSpace(input[cursor:i]))
|
|
i++
|
|
cursor = i
|
|
|
|
var quoted bool
|
|
if input[cursor] == '"' {
|
|
quoted = true
|
|
i++
|
|
cursor = i
|
|
}
|
|
|
|
var escaped bool
|
|
|
|
for j := cursor; j < len(input); j++ {
|
|
switch input[j] {
|
|
case '"':
|
|
if !quoted {
|
|
return nil, fmt.Errorf("illegal DQUOTE at position %d", j)
|
|
}
|
|
if !escaped {
|
|
// end of quoted value
|
|
rawVal = input[cursor:j]
|
|
j++
|
|
cursor = j
|
|
break keyValPair
|
|
}
|
|
case '\\':
|
|
escaped = true
|
|
case ' ', '\t', '\n', '\r':
|
|
if !quoted {
|
|
// end of unquoted value
|
|
rawVal = input[cursor:j]
|
|
cursor = j
|
|
break keyValPair
|
|
}
|
|
default:
|
|
escaped = false
|
|
}
|
|
}
|
|
|
|
case ' ', '\t', '\n', '\r':
|
|
// key with no value (flag)
|
|
key = input[cursor:i]
|
|
params[key] = []string{}
|
|
cursor = i
|
|
break keyValPair
|
|
}
|
|
}
|
|
|
|
if rawVal == "" {
|
|
continue
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
var escape int // start of escape sequence (after \, so 0 is never a valid start)
|
|
for i := 0; i < len(rawVal); i++ {
|
|
ch := rawVal[i]
|
|
if escape > 0 {
|
|
// validate escape sequence
|
|
// (RFC 9460 Appendix A)
|
|
// escaped: "\" ( non-digit / dec-octet )
|
|
// non-digit: "%x21-2F / %x3A-7E"
|
|
// dec-octet: "0-255 as a 3-digit decimal number"
|
|
if ch >= '0' && ch <= '9' {
|
|
// advance to end of decimal octet, which must be 3 digits
|
|
i += 2
|
|
if i > len(rawVal) {
|
|
return nil, fmt.Errorf("value ends with incomplete escape sequence: %s", rawVal[escape:])
|
|
}
|
|
decOctet, err := strconv.Atoi(rawVal[escape : i+1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if decOctet < 0 || decOctet > 255 {
|
|
return nil, fmt.Errorf("invalid decimal octet in escape sequence: %s (%d)", rawVal[escape:i], decOctet)
|
|
}
|
|
sb.WriteRune(rune(decOctet))
|
|
escape = 0
|
|
continue
|
|
} else if (ch < 0x21 || ch > 0x2F) && (ch < 0x3A && ch > 0x7E) {
|
|
return nil, fmt.Errorf("illegal escape sequence %s", rawVal[escape:i])
|
|
}
|
|
}
|
|
switch ch {
|
|
case ';', '(', ')':
|
|
// RFC 9460 Appendix A:
|
|
// > contiguous = 1*( non-special / escaped )
|
|
// > non-special is VCHAR minus DQUOTE, ";", "(", ")", and "\".
|
|
return nil, fmt.Errorf("illegal character in value %q at position %d: %s", rawVal, i, string(ch))
|
|
case '\\':
|
|
escape = i + 1
|
|
default:
|
|
sb.WriteByte(ch)
|
|
escape = 0
|
|
}
|
|
}
|
|
|
|
params[key] = strings.Split(sb.String(), ",")
|
|
}
|
|
|
|
return params, nil
|
|
}
|
|
|
|
// String serializes svcParams into zone presentation format.
|
|
func (params svcParams) String() string {
|
|
var sb strings.Builder
|
|
for key, vals := range params {
|
|
if sb.Len() > 0 {
|
|
sb.WriteRune(' ')
|
|
}
|
|
sb.WriteString(key)
|
|
var hasVal, needsQuotes bool
|
|
for _, val := range vals {
|
|
if len(val) > 0 {
|
|
hasVal = true
|
|
}
|
|
if strings.ContainsAny(val, `" `) {
|
|
needsQuotes = true
|
|
}
|
|
if hasVal && needsQuotes {
|
|
break
|
|
}
|
|
}
|
|
if hasVal {
|
|
sb.WriteRune('=')
|
|
}
|
|
if needsQuotes {
|
|
sb.WriteRune('"')
|
|
}
|
|
for i, val := range vals {
|
|
if i > 0 {
|
|
sb.WriteRune(',')
|
|
}
|
|
val = strings.ReplaceAll(val, `"`, `\"`)
|
|
val = strings.ReplaceAll(val, `,`, `\,`)
|
|
sb.WriteString(val)
|
|
}
|
|
if needsQuotes {
|
|
sb.WriteRune('"')
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// ECHPublisher is an interface for publishing ECHConfigList values
|
|
// so that they can be used by clients.
|
|
type ECHPublisher interface {
|
|
// Returns a key that is unique to this publisher and its configuration.
|
|
// A publisher's ID combined with its config is a valid key.
|
|
// It is used to prevent duplicating publications.
|
|
PublisherKey() string
|
|
|
|
// Publishes the ECH config list for the given innerNames. Some publishers
|
|
// may not need a list of inner/protected names, and can ignore the argument;
|
|
// most, however, will want to use it to know which inner names are to be
|
|
// associated with the given ECH config list.
|
|
PublishECHConfigList(ctx context.Context, innerNames []string, echConfigList []byte) error
|
|
}
|
|
|
|
type echConfigMeta struct {
|
|
Created time.Time `json:"created"`
|
|
Publications publicationHistory `json:"publications"`
|
|
}
|
|
|
|
// publicationHistory is a map of publisher key to
|
|
// map of inner name to timestamp
|
|
type publicationHistory map[string]map[string]time.Time
|
|
|
|
// The key prefix when putting ECH configs in storage. After this
|
|
// comes the config ID.
|
|
const echConfigsKey = "ech/configs"
|
|
|
|
// https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html
|
|
const draftTLSESNI22 = 0xfe0d
|
|
|
|
// Interface guard
|
|
var _ ECHPublisher = (*ECHDNSPublisher)(nil)
|