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. // // Note that, as of Caddy 2.10 (~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. 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.OuterSNI)) 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.Error("publishing ECH configuration list", zap.Strings("for_domains", publication.Domains), 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) } } } } 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 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 securely reconciling inconsistent client state without // breakage and brittleness. OuterSNI string `json:"outer_sni,omitempty"` } // ECHPublication configures publication of ECH config(s). type ECHPublication struct { // TODO: Should these first two fields be called outer_sni and inner_sni ? // The list of ECH configurations to publish, identified by public name. // If not set, all configs will be included for publication by default. Configs []string `json:"configs,omitempty"` // The list of domain names which are protected with the associated ECH // configurations ("inner names"). Not all publishers may require this // information, but some, like the DNS publisher, do. (The DNS publisher, // for example, needs to know for which domain(s) to create DNS records.) // // If not set, all server names registered with the TLS module will be // added to this list implicitly. (Other Caddy apps that use the TLS // module automatically register their configured server names for this // purpose. For example, the HTTP server registers the hostnames for // which it applies automatic HTTPS.) // // 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 them. // Note that ECH configs are only published when they are newly created, // so adding or changing publishers after the fact will have no effect // with existing ECH configs. The next time a config is generated (including // when a key is rotated), the current publication modules will be utilized. 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 } // get any existing HTTPS record for this domain, and augment // our 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 } relName := libdns.RelativeName(domain+".", zone) var httpsRec libdns.Record for _, rec := range recs { if rec.Name == relName && rec.Type == "HTTPS" && (rec.Target == "" || rec.Target == ".") { httpsRec = rec } } 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 { dnsPub.logger.Error("unable to publish ECH data to HTTPS DNS record", zap.String("domain", domain), 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)