feat: add support for Encrypted ClientHello keys and related configurations

rabbitprincess committed May 5, 2026 at 00:09 UTC 9f8aed597cfa7e9bee8b3889a3993f77437a11c6
8 files changed +157 -25
docs/src/routes/configuration/+page.md
+1
@@ -228,6 +228,7 @@ Stores the secp256k1 identity used to sign tunnel sessions and relay descriptors
228 | `admin_secret_key` | string | Relay-only admin login secret, generated automatically when missing |
229 | `wireguard_public_key` | string | Relay-only WireGuard overlay public key when discovery is enabled |
230 | `wireguard_private_key` | string | Relay-only WireGuard overlay private key when discovery is enabled |
231 +| `encrypted_client_hello_seed` | string | Relay-only HKDF salt for deriving the ECH HPKE private key; generated automatically when missing; keep secret |
232
233 The same identity file or state directory can be reused across restarts to keep a stable address.
234
go.mod
+1 -1
@@ -16,7 +16,7 @@ require (
16 github.com/go-acme/lego/v4 v4.34.0
17 github.com/go-jose/go-jose/v4 v4.1.4
18 github.com/go-rod/rod v0.116.2
19 - github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
19 + github.com/gosuda/keyless_tls v0.0.1
20 github.com/hashicorp/yamux v0.1.2
21 github.com/knadh/koanf/parsers/toml/v2 v2.2.0
22 github.com/knadh/koanf/providers/file v1.2.1
go.sum
+2
@@ -113,6 +113,8 @@ github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl
113 github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
114 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
115 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
116 +github.com/gosuda/keyless_tls v0.0.1 h1:IGuGHxqqxSTJL+7kHPwdoLveXpBbOhBGm/T4gnVTUtU=
117 +github.com/gosuda/keyless_tls v0.0.1/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
118 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
119 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
120 github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
portal/keyless/ech.go new
+94
@@ -0,0 +1,94 @@
1 +package keyless
2 +
3 +import (
4 + "bytes"
5 + "crypto/ecdh"
6 + "crypto/hkdf"
7 + "crypto/sha256"
8 + "crypto/tls"
9 + "encoding/binary"
10 + "errors"
11 + "fmt"
12 + "strings"
13 +
14 + "github.com/gosuda/portal-tunnel/v2/utils"
15 +)
16 +
17 +const (
18 + echConfigVersion = 0xfe0d
19 + echKEMX25519 = 0x0020
20 + echKDFHKDFSHA256 = 0x0001
21 + echAEADAES128GCM = 0x0001
22 + echMaximumNameLength = 255
23 + echX25519PrivateLength = 32
24 + echHKDFInfoPrefix = "portal relay ech v1:"
25 +)
26 +
27 +func EncryptedClientHelloKeys(siwePrivateKey, seed, publicName string) ([]tls.EncryptedClientHelloKey, error) {
28 + publicName = utils.NormalizeHostname(publicName)
29 + if publicName == "" {
30 + return nil, errors.New("ech public name is required")
31 + }
32 + signingKey, _, err := utils.ParseSecp256k1PrivateKeyHex(siwePrivateKey, true)
33 + if err != nil {
34 + return nil, fmt.Errorf("parse siwe private key: %w", err)
35 + }
36 + seed = strings.TrimSpace(seed)
37 + if seed == "" {
38 + return nil, errors.New("ech seed is required")
39 + }
40 +
41 + if len(publicName) > echMaximumNameLength {
42 + return nil, errors.New("ech public name is too long")
43 + }
44 +
45 + privateKey, err := hkdf.Key(sha256.New, signingKey.Serialize(), []byte(seed), echHKDFInfoPrefix+publicName, echX25519PrivateLength)
46 + if err != nil {
47 + return nil, fmt.Errorf("derive ech private key: %w", err)
48 + }
49 + key, err := ecdh.X25519().NewPrivateKey(privateKey)
50 + if err != nil {
51 + return nil, fmt.Errorf("parse ech private key: %w", err)
52 + }
53 + publicKey := key.PublicKey().Bytes()
54 + configID := sha256.Sum256(bytes.Join([][]byte{
55 + []byte("portal relay ech config id v1"),
56 + []byte(publicName),
57 + publicKey,
58 + }, []byte{0}))[0]
59 +
60 + writeUint16 := func(buf *bytes.Buffer, value uint16) {
61 + var out [2]byte
62 + binary.BigEndian.PutUint16(out[:], value)
63 + buf.Write(out[:])
64 + }
65 + writeUint16LengthPrefixed := func(buf *bytes.Buffer, data []byte) {
66 + writeUint16(buf, uint16(len(data)))
67 + buf.Write(data)
68 + }
69 +
70 + var body bytes.Buffer
71 + body.WriteByte(configID)
72 + writeUint16(&body, echKEMX25519)
73 + writeUint16LengthPrefixed(&body, publicKey)
74 +
75 + var cipherSuites bytes.Buffer
76 + writeUint16(&cipherSuites, echKDFHKDFSHA256)
77 + writeUint16(&cipherSuites, echAEADAES128GCM)
78 + writeUint16LengthPrefixed(&body, cipherSuites.Bytes())
79 +
80 + body.WriteByte(echMaximumNameLength)
81 + body.WriteByte(byte(len(publicName)))
82 + body.WriteString(publicName)
83 + writeUint16(&body, 0)
84 +
85 + var out bytes.Buffer
86 + writeUint16(&out, echConfigVersion)
87 + writeUint16LengthPrefixed(&out, body.Bytes())
88 +
89 + return []tls.EncryptedClientHelloKey{{
90 + Config: out.Bytes(),
91 + PrivateKey: privateKey,
92 + SendAsRetry: true,
93 + }}, nil
94 +}
portal/keyless/tls.go
+19 -8
@@ -11,9 +11,10 @@ import (
11 )
12
13 type TLSMaterialConfig struct {
14 - Keyless *RemoteSignerConfig
15 - CertPEM []byte
16 - KeyPEM []byte
14 + Keyless *RemoteSignerConfig
15 + CertPEM []byte
16 + KeyPEM []byte
17 + EncryptedClientHelloKeys []tls.EncryptedClientHelloKey
18 }
19
20 type RemoteSignerConfig struct {
@@ -30,6 +31,10 @@ func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer,
31 return nil, errors.New("http server is required")
32 }
33 if cfg.Keyless != nil {
34 + minVersion := tls.VersionTLS12
35 + if len(cfg.EncryptedClientHelloKeys) > 0 {
36 + minVersion = tls.VersionTLS13
37 + }
38 remoteSigner, err := keylesstls.AttachToHTTPServer(server, keylesstls.HTTPServerAttachConfig{
39 CertPEM: cfg.CertPEM,
40 RemoteSigner: keylesstls.RemoteSignerConfig{
@@ -40,8 +45,9 @@ func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer,
45 ClientKeyPEM: cfg.Keyless.ClientKeyPEM,
46 RootCAPEM: cfg.Keyless.RootCAPEM,
47 },
43 - NextProtos: []string{"http/1.1"},
44 - MinTLSVersion: tls.VersionTLS12,
48 + NextProtos: []string{"http/1.1"},
49 + MinTLSVersion: uint16(minVersion),
50 + EncryptedClientHelloKeys: cfg.EncryptedClientHelloKeys,
51 })
52 if err != nil {
53 return nil, err
@@ -54,10 +60,15 @@ func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer,
60 return nil, fmt.Errorf("parse api tls key pair: %w", err)
61 }
62
63 + minVersion := tls.VersionTLS12
64 + if len(cfg.EncryptedClientHelloKeys) > 0 {
65 + minVersion = tls.VersionTLS13
66 + }
67 server.TLSConfig = &tls.Config{
58 - MinVersion: tls.VersionTLS12,
59 - NextProtos: []string{"http/1.1"},
60 - Certificates: []tls.Certificate{cert},
68 + MinVersion: uint16(minVersion),
69 + NextProtos: []string{"http/1.1"},
70 + Certificates: []tls.Certificate{cert},
71 + EncryptedClientHelloKeys: cfg.EncryptedClientHelloKeys,
72 }
73 return nil, nil
74 }
portal/server.go
+13
@@ -321,6 +321,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
321 Bool("multihop_enabled", s.hopMux != nil).
322 Bool("udp_enabled", s.quicBackhaul != nil).
323 Bool("tcp_enabled", s.cfg.TCPEnabled).
324 + Bool("ech_enabled", len(apiTLS.EncryptedClientHelloKeys) > 0).
325 Bool("pprof_enabled", s.pprofServer != nil)
326 if s.pprofListener != nil {
327 logEvent = logEvent.Str("pprof_addr", utils.HostPortOrLoopback(s.pprofListener.Addr().String()))
@@ -453,6 +454,18 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
454 CertPEM: certPEM,
455 KeyPEM: keyPEM,
456 }
457 + echKeys, err := keyless.EncryptedClientHelloKeys(
458 + s.identity.PrivateKey,
459 + s.identity.EncryptedClientHelloSeed,
460 + s.identity.Name,
461 + )
462 + if err != nil {
463 + manager.Stop()
464 + return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("prepare ech keys: %w", err)
465 + }
466 + if len(echKeys) > 0 {
467 + apiTLS.EncryptedClientHelloKeys = echKeys
468 + }
469
470 return apiTLS, manager, nil
471 }
types/identity.go
+9 -7
@@ -35,17 +35,19 @@ func (i Identity) Copy() Identity {
35
36 type RelayIdentity struct {
37 Identity
38 - AdminSecretKey string `json:"-"`
39 - WireGuardPublicKey string `json:"-"`
40 - WireGuardPrivateKey string `json:"-"`
38 + AdminSecretKey string `json:"-"`
39 + WireGuardPublicKey string `json:"-"`
40 + WireGuardPrivateKey string `json:"-"`
41 + EncryptedClientHelloSeed string `json:"-"`
42 }
43
44 func (i RelayIdentity) Copy() RelayIdentity {
45 return RelayIdentity{
45 - Identity: i.Identity.Copy(),
46 - AdminSecretKey: i.AdminSecretKey,
47 - WireGuardPublicKey: i.WireGuardPublicKey,
48 - WireGuardPrivateKey: i.WireGuardPrivateKey,
46 + Identity: i.Identity.Copy(),
47 + AdminSecretKey: i.AdminSecretKey,
48 + WireGuardPublicKey: i.WireGuardPublicKey,
49 + WireGuardPrivateKey: i.WireGuardPrivateKey,
50 + EncryptedClientHelloSeed: i.EncryptedClientHelloSeed,
51 }
52 }
53
utils/identity.go
+18 -9
@@ -193,6 +193,7 @@ func NormalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIden
193 normalized.AdminSecretKey = strings.TrimSpace(normalized.AdminSecretKey)
194 normalized.WireGuardPublicKey = strings.TrimSpace(normalized.WireGuardPublicKey)
195 normalized.WireGuardPrivateKey = strings.TrimSpace(normalized.WireGuardPrivateKey)
196 + normalized.EncryptedClientHelloSeed = strings.TrimSpace(normalized.EncryptedClientHelloSeed)
197
198 switch {
199 case normalized.WireGuardPrivateKey != "":
@@ -219,6 +220,7 @@ func NormalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIden
220 return types.RelayIdentity{}, err
221 }
222 }
223 +
224 return normalized, nil
225 }
226
@@ -231,9 +233,10 @@ type storedIdentity struct {
233
234 type storedRelayIdentity struct {
235 storedIdentity
234 - AdminSecretKey string `json:"admin_secret_key,omitempty"`
235 - WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
236 - WireGuardPrivateKey string `json:"wireguard_private_key,omitempty"`
236 + AdminSecretKey string `json:"admin_secret_key,omitempty"`
237 + WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
238 + WireGuardPrivateKey string `json:"wireguard_private_key,omitempty"`
239 + EncryptedClientHelloSeed string `json:"encrypted_client_hello_seed,omitempty"`
240 }
241
242 func SaveIdentity(path string, identity types.Identity) error {
@@ -272,9 +275,10 @@ func SaveRelayIdentity(path string, identity types.RelayIdentity) error {
275 PublicKey: normalized.PublicKey,
276 PrivateKey: normalized.PrivateKey,
277 },
275 - AdminSecretKey: normalized.AdminSecretKey,
276 - WireGuardPublicKey: normalized.WireGuardPublicKey,
277 - WireGuardPrivateKey: normalized.WireGuardPrivateKey,
278 + AdminSecretKey: normalized.AdminSecretKey,
279 + WireGuardPublicKey: normalized.WireGuardPublicKey,
280 + WireGuardPrivateKey: normalized.WireGuardPrivateKey,
281 + EncryptedClientHelloSeed: normalized.EncryptedClientHelloSeed,
282 }, 0o600); err != nil {
283 return fmt.Errorf("write identity file: %w", err)
284 }
@@ -314,9 +318,10 @@ func LoadRelayIdentity(path string) (types.RelayIdentity, error) {
318 PublicKey: payload.PublicKey,
319 PrivateKey: payload.PrivateKey,
320 },
317 - AdminSecretKey: payload.AdminSecretKey,
318 - WireGuardPublicKey: payload.WireGuardPublicKey,
319 - WireGuardPrivateKey: payload.WireGuardPrivateKey,
321 + AdminSecretKey: payload.AdminSecretKey,
322 + WireGuardPublicKey: payload.WireGuardPublicKey,
323 + WireGuardPrivateKey: payload.WireGuardPrivateKey,
324 + EncryptedClientHelloSeed: payload.EncryptedClientHelloSeed,
325 })
326 }
327
@@ -480,6 +485,10 @@ func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool)
485 identity.WireGuardPrivateKey = wireGuardPrivateKey
486 }
487
488 + if strings.TrimSpace(identity.EncryptedClientHelloSeed) == "" {
489 + identity.EncryptedClientHelloSeed = RandomID("")
490 + }
491 +
492 return nil
493 }
494