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