Add dynamic cert noise calibration for FakeTLS handshake

The hardcoded noise range (2500-4700 bytes) in the FakeTLS ServerHello
does not match the real certificate chain sizes of many popular fronting
domains (e.g., dl.google.com ≈ 6480 bytes, microsoft.com ≈ 13004 bytes).
This makes the proxy detectable by DPI systems that compare the
ApplicationData size with the real cert chain size for the SNI domain.

On startup, probe the fronting domain's actual TLS handshake size and
use the measured value ± jitter instead of the static range. Falls back
to the legacy 2500-4700 range if the probe fails.

Also adds optional caching of probe results between restarts
(noise-cache-path, noise-cache-ttl) and a configurable probe count
(noise-probe-count) under [defense.doppelganger].

Closes #408
This commit is contained in:
Alexey Dolotov
2026-03-26 23:38:58 +03:00
parent d32e8e8b97
commit 80213ad35d
8 changed files with 405 additions and 29 deletions
+261
View File
@@ -0,0 +1,261 @@
package fake
import (
"crypto/tls"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"os"
"sync"
"time"
)
const (
probeDialTimeout = 10 * time.Second
probeHandshakeTimeout = 10 * time.Second
defaultProbeCount = 15
defaultCacheTTL = 24 * time.Hour
tlsTypeChangeCipherSpec = 0x14
tlsTypeApplicationData = 0x17
)
// CertProbeResult holds the measured encrypted handshake size.
type CertProbeResult struct {
Mean int `json:"mean"`
Jitter int `json:"jitter"`
}
// CertProbeCache is the on-disk format for cached probe results.
type CertProbeCache struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
Mean int `json:"mean"`
Jitter int `json:"jitter"`
ProbedAt time.Time `json:"probed_at"`
}
// LoadCachedProbe reads a cached probe result from path. Returns the result
// and true if the cache exists, matches hostname:port, and is younger than ttl.
// Otherwise returns zero value and false.
func LoadCachedProbe(path, hostname string, port int, ttl time.Duration) (CertProbeResult, bool) {
if ttl <= 0 {
ttl = defaultCacheTTL
}
data, err := os.ReadFile(path)
if err != nil {
return CertProbeResult{}, false
}
var cache CertProbeCache
if err := json.Unmarshal(data, &cache); err != nil {
return CertProbeResult{}, false
}
if cache.Hostname != hostname || cache.Port != port {
return CertProbeResult{}, false
}
if time.Since(cache.ProbedAt) > ttl {
return CertProbeResult{}, false
}
if cache.Mean <= 0 {
return CertProbeResult{}, false
}
return CertProbeResult{Mean: cache.Mean, Jitter: cache.Jitter}, true
}
// SaveCachedProbe writes a probe result to path as JSON.
func SaveCachedProbe(path, hostname string, port int, result CertProbeResult) error {
cache := CertProbeCache{
Hostname: hostname,
Port: port,
Mean: result.Mean,
Jitter: result.Jitter,
ProbedAt: time.Now(),
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644) //nolint: gosec
}
// ProbeCertSize connects to hostname:port via TLS multiple times and measures
// the total ApplicationData payload bytes sent by the server during the
// handshake (between ChangeCipherSpec and the first application-level data).
// This corresponds to EncryptedExtensions + Certificate + CertificateVerify +
// Finished in TLS 1.3, which is what the FakeTLS noise must mimic.
func ProbeCertSize(hostname string, port int, count int) (CertProbeResult, error) {
if count <= 0 {
count = defaultProbeCount
}
addr := net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
sizes := make([]int, 0, count)
for i := 0; i < count; i++ {
size, err := probeSingle(addr, hostname)
if err != nil {
if len(sizes) > 0 {
break // use what we have
}
return CertProbeResult{}, fmt.Errorf("probe %d failed: %w", i, err)
}
sizes = append(sizes, size)
}
if len(sizes) == 0 {
return CertProbeResult{}, fmt.Errorf("no successful probes")
}
// Calculate mean and jitter (max deviation from mean).
sum := 0
for _, s := range sizes {
sum += s
}
mean := sum / len(sizes)
maxDev := 0
for _, s := range sizes {
d := s - mean
if d < 0 {
d = -d
}
if d > maxDev {
maxDev = d
}
}
// Ensure minimum jitter of 100 bytes for variability.
if maxDev < 100 {
maxDev = 100
}
return CertProbeResult{Mean: mean, Jitter: maxDev}, nil
}
// probeSingle does one TLS handshake and measures ApplicationData bytes
// received during the handshake.
func probeSingle(addr, hostname string) (int, error) {
rawConn, err := net.DialTimeout("tcp", addr, probeDialTimeout)
if err != nil {
return 0, err
}
defer rawConn.Close() //nolint: errcheck
capture := &recordCapture{conn: rawConn}
tlsConn := tls.Client(capture, &tls.Config{
ServerName: hostname,
MinVersion: tls.VersionTLS12,
})
tlsConn.SetDeadline(time.Now().Add(probeHandshakeTimeout)) //nolint: errcheck
if err := tlsConn.Handshake(); err != nil {
return 0, err
}
tlsConn.Close() //nolint: errcheck
return capture.appDataBytes, nil
}
// recordCapture wraps a net.Conn and parses the raw TLS record stream to
// measure ApplicationData payload sizes sent by the server during handshake.
// It tracks record boundaries by maintaining a state machine over Read calls.
type recordCapture struct {
conn net.Conn
mu sync.Mutex
appDataBytes int
seenCCS bool
done bool
// Record boundary tracking for the read side.
readRemaining int // bytes left in current record payload
readHeaderBuf [5]byte
readHeaderPos int
}
func (rc *recordCapture) Read(p []byte) (int, error) {
n, err := rc.conn.Read(p)
if n > 0 && !rc.done {
rc.mu.Lock()
rc.parseReadBytes(p[:n])
rc.mu.Unlock()
}
return n, err
}
func (rc *recordCapture) parseReadBytes(data []byte) {
for len(data) > 0 {
if rc.readRemaining > 0 {
// Consuming payload of current record.
consume := rc.readRemaining
if consume > len(data) {
consume = len(data)
}
rc.readRemaining -= consume
data = data[consume:]
continue
}
// Accumulate header bytes (5 bytes per record).
need := 5 - rc.readHeaderPos
if need > len(data) {
need = len(data)
}
copy(rc.readHeaderBuf[rc.readHeaderPos:], data[:need])
rc.readHeaderPos += need
data = data[need:]
if rc.readHeaderPos < 5 {
return // incomplete header
}
// Full header available.
recordType := rc.readHeaderBuf[0]
payloadLen := int(binary.BigEndian.Uint16(rc.readHeaderBuf[3:5]))
rc.readHeaderPos = 0
rc.readRemaining = payloadLen
if recordType == tlsTypeChangeCipherSpec {
rc.seenCCS = true
} else if recordType == tlsTypeApplicationData && rc.seenCCS {
rc.appDataBytes += payloadLen
}
}
}
func (rc *recordCapture) Write(p []byte) (int, error) {
// After client writes post-CCS data, server handshake records are done.
if rc.seenCCS && rc.appDataBytes > 0 {
rc.done = true
}
return rc.conn.Write(p)
}
func (rc *recordCapture) Close() error { return rc.conn.Close() }
func (rc *recordCapture) LocalAddr() net.Addr { return rc.conn.LocalAddr() }
func (rc *recordCapture) RemoteAddr() net.Addr { return rc.conn.RemoteAddr() }
func (rc *recordCapture) SetDeadline(t time.Time) error { return rc.conn.SetDeadline(t) }
func (rc *recordCapture) SetReadDeadline(t time.Time) error { return rc.conn.SetReadDeadline(t) }
func (rc *recordCapture) SetWriteDeadline(t time.Time) error { return rc.conn.SetWriteDeadline(t) }
// Ensure recordCapture implements net.Conn.
var _ net.Conn = (*recordCapture)(nil)
+33 -14
View File
@@ -9,11 +9,18 @@ import (
"io"
rnd "math/rand/v2"
"github.com/9seconds/mtg/v2/mtglib/internal/doppel"
"github.com/9seconds/mtg/v2/mtglib/internal/tls"
"golang.org/x/crypto/curve25519"
)
// NoiseParams controls the size of the fake ApplicationData record
// in ServerHello. If Mean is 0, the legacy random range (2500-4700)
// is used.
type NoiseParams struct {
Mean int
Jitter int
}
const (
TypeHandshakeServer = 0x02
ChangeCipherValue = 0x01
@@ -33,13 +40,13 @@ var serverHelloSuffix = []byte{
0x00, 0x20, // 32 bytes of key
}
func SendServerHello(w io.Writer, secret []byte, clientHello *ClientHello) error {
func SendServerHello(w io.Writer, secret []byte, clientHello *ClientHello, noise NoiseParams) error {
buf := &bytes.Buffer{}
buf.Grow(tls.MaxRecordSize)
generateServerHello(buf, clientHello)
generateChangeCipherValue(buf)
generateNoise(buf)
generateNoise(buf, noise)
packet := buf.Bytes()
digest := hmac.New(sha256.New, secret)
@@ -125,19 +132,31 @@ func generateChangeCipherValue(buf *bytes.Buffer) {
buf.WriteByte(ChangeCipherValue)
}
func generateNoise(buf *bytes.Buffer) {
data := make(
[]byte,
int64(
doppel.TLSRecordSizeStart+rnd.IntN(
doppel.TLSRecordSizeAccel-doppel.TLSRecordSizeStart,
),
),
)
// generateNoise writes a single ApplicationData record mimicking the combined
// size of a real TLS 1.3 encrypted server handshake (EncryptedExtensions +
// Certificate chain + CertificateVerify + Finished).
//
// NOTE: Must be exactly ONE ApplicationData record — the Telegram client reads
// ServerHello + CCS + 1 ApplicationData and computes HMAC over all three.
// Multiple records would cause HMAC mismatch and connection failure.
func generateNoise(buf *bytes.Buffer, noise NoiseParams) {
var size int
if _, err := rand.Read(data[:]); err != nil {
if noise.Mean > 0 && noise.Jitter > 0 {
// Calibrated: use measured cert chain size ± jitter.
size = noise.Mean - noise.Jitter + rnd.IntN(2*noise.Jitter)
if size < 1000 {
size = 1000
}
} else {
// Legacy fallback: random in 2500-4700 range.
size = 2500 + rnd.IntN(2200)
}
data := make([]byte, size)
if _, err := rand.Read(data); err != nil {
panic(err)
}
tls.WriteRecord(buf, data[:]) //nolint: errcheck
tls.WriteRecord(buf, data) //nolint: errcheck
}
+32 -6
View File
@@ -8,7 +8,6 @@ import (
"testing"
"github.com/9seconds/mtg/v2/mtglib"
"github.com/9seconds/mtg/v2/mtglib/internal/doppel"
"github.com/9seconds/mtg/v2/mtglib/internal/tls"
"github.com/9seconds/mtg/v2/mtglib/internal/tls/fake"
"github.com/stretchr/testify/suite"
@@ -39,7 +38,7 @@ func (suite *SendServerHelloTestSuite) SetupTest() {
}
func (suite *SendServerHelloTestSuite) TestRecordStructure() {
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello)
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello, fake.NoiseParams{})
suite.NoError(err)
var rec bytes.Buffer
@@ -59,13 +58,13 @@ func (suite *SendServerHelloTestSuite) TestRecordStructure() {
recordType, length, err := tls.ReadRecord(suite.buf, &rec)
suite.NoError(err)
suite.Equal(byte(tls.TypeApplicationData), recordType)
suite.Greater(length, int64(doppel.TLSRecordSizeStart))
suite.Greater(length, int64(2500))
suite.Empty(suite.buf.Bytes())
}
func (suite *SendServerHelloTestSuite) TestHMAC() {
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello)
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello, fake.NoiseParams{})
suite.NoError(err)
packet := make([]byte, suite.buf.Len())
@@ -83,7 +82,7 @@ func (suite *SendServerHelloTestSuite) TestHMAC() {
}
func (suite *SendServerHelloTestSuite) TestHandshakePayload() {
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello)
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello, fake.NoiseParams{})
suite.NoError(err)
packet := suite.buf.Bytes()
@@ -105,7 +104,7 @@ func (suite *SendServerHelloTestSuite) TestHandshakePayload() {
}
func (suite *SendServerHelloTestSuite) TestChangeCipherSpec() {
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello)
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello, fake.NoiseParams{})
suite.NoError(err)
// Skip first record
@@ -124,6 +123,33 @@ func (suite *SendServerHelloTestSuite) TestChangeCipherSpec() {
suite.Equal([]byte{fake.ChangeCipherValue}, rec.Bytes())
}
func (suite *SendServerHelloTestSuite) TestCalibratedNoiseSize() {
noise := fake.NoiseParams{Mean: 6480, Jitter: 100}
err := fake.SendServerHello(suite.buf, suite.secret.Key[:], suite.hello, noise)
suite.NoError(err)
var rec bytes.Buffer
// Skip ServerHello
_, _, err = tls.ReadRecord(suite.buf, &rec)
suite.NoError(err)
// Skip ChangeCipherSpec
rec.Reset()
_, _, err = tls.ReadRecord(suite.buf, &rec)
suite.NoError(err)
// Read noise ApplicationData
rec.Reset()
recordType, length, err := tls.ReadRecord(suite.buf, &rec)
suite.NoError(err)
suite.Equal(byte(tls.TypeApplicationData), recordType)
// Should be within mean ± jitter range.
suite.GreaterOrEqual(length, int64(noise.Mean-noise.Jitter))
suite.LessOrEqual(length, int64(noise.Mean+noise.Jitter))
}
func TestSendServerHello(t *testing.T) {
t.Parallel()
suite.Run(t, &SendServerHelloTestSuite{})
+42 -1
View File
@@ -36,6 +36,7 @@ type Proxy struct {
doppelGanger *doppel.Ganger
clientObfuscatror obfuscation.Obfuscator
noiseParams fake.NoiseParams
secret Secret
network Network
antiReplayCache AntiReplayCache
@@ -192,7 +193,7 @@ func (p *Proxy) doFakeTLSHandshake(ctx *streamContext) bool {
return false
}
if err := fake.SendServerHello(ctx.clientConn, p.secret.Key[:], clientHello); err != nil {
if err := fake.SendServerHello(ctx.clientConn, p.secret.Key[:], clientHello, p.noiseParams); err != nil {
p.logger.InfoError("cannot send welcome packet", err)
return false
}
@@ -323,9 +324,49 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) {
logger := opts.getLogger("proxy")
updatersLogger := logger.Named("telegram-updaters")
// Probe the fronting domain's cert chain size for noise calibration.
probeHost := opts.Secret.Host
probePort := opts.getDomainFrontingPort()
noiseParams := fake.NoiseParams{}
probeCount := int(opts.NoiseProbeCount)
if probeCount <= 0 {
probeCount = 15
}
cacheTTL := opts.NoiseCacheTTL
// Try loading from cache first.
if opts.NoiseCachePath != "" {
if cached, ok := fake.LoadCachedProbe(opts.NoiseCachePath, probeHost, probePort, cacheTTL); ok {
noiseParams = fake.NoiseParams(cached)
logger.Info(fmt.Sprintf("cert probe: loaded from cache, host=%s mean=%d jitter=%d",
probeHost, cached.Mean, cached.Jitter))
}
}
// If no cached result, probe live.
if noiseParams.Mean == 0 {
probeResult, probeErr := fake.ProbeCertSize(probeHost, probePort, probeCount)
if probeErr != nil {
logger.WarningError("cert probe failed, using default noise size", probeErr)
} else {
noiseParams = fake.NoiseParams(probeResult)
logger.Info(fmt.Sprintf("cert probe: host=%s mean=%d jitter=%d",
probeHost, probeResult.Mean, probeResult.Jitter))
if opts.NoiseCachePath != "" {
if saveErr := fake.SaveCachedProbe(opts.NoiseCachePath, probeHost, probePort, probeResult); saveErr != nil {
logger.WarningError("failed to save cert probe cache", saveErr)
}
}
}
}
proxy := &Proxy{
ctx: ctx,
ctxCancel: cancel,
noiseParams: noiseParams,
secret: opts.Secret,
network: opts.Network,
antiReplayCache: opts.AntiReplayCache,
+19
View File
@@ -160,6 +160,25 @@ type ProxyOpts struct {
// DoppelGangerDRS defines if TLS Dynamic Record Sizing is active.
DoppelGangerDRS bool
// NoiseProbeCount is the number of TLS connections to make when probing
// the fronting domain's cert chain size for noise calibration.
// Default is 15.
//
// This is an optional setting.
NoiseProbeCount uint
// NoiseCacheTTL is how long a cached cert probe result is considered
// valid. Default is 24 hours.
//
// This is an optional setting.
NoiseCacheTTL time.Duration
// NoiseCachePath is the file path for caching cert probe results
// between restarts. If empty, no caching is performed.
//
// This is an optional setting.
NoiseCachePath string
}
func (p ProxyOpts) valid() error {