diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index 643f645..1deac95 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -267,6 +267,10 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid), DoppelGangerEach: conf.Defense.Doppelganger.UpdateEach.Get(mtglib.DoppelGangerEach), DoppelGangerDRS: conf.Defense.Doppelganger.DRS.Get(false), + + NoiseProbeCount: conf.Defense.Doppelganger.NoiseProbeCount.Get(0), + NoiseCacheTTL: conf.Defense.Doppelganger.NoiseCacheTTL.Get(0), + NoiseCachePath: conf.Defense.Doppelganger.NoiseCachePath, } proxy, err := mtglib.NewProxy(opts) diff --git a/internal/config/config.go b/internal/config/config.go index cb51c63..e266d16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,10 +50,13 @@ type Config struct { Blocklist ListConfig `json:"blocklist"` Allowlist ListConfig `json:"allowlist"` Doppelganger struct { - URLs []TypeHttpsURL `json:"urls"` - Repeats TypeConcurrency `json:"repeats_per_raid"` - UpdateEach TypeDuration `json:"raid_each"` - DRS TypeBool `json:"drs"` + URLs []TypeHttpsURL `json:"urls"` + Repeats TypeConcurrency `json:"repeats_per_raid"` + UpdateEach TypeDuration `json:"raid_each"` + DRS TypeBool `json:"drs"` + NoiseProbeCount TypeConcurrency `json:"noise_probe_count"` + NoiseCacheTTL TypeDuration `json:"noise_cache_ttl"` + NoiseCachePath string `json:"noise_cache_path"` } `json:"doppelganger"` } `json:"defense"` Network struct { diff --git a/internal/config/parse.go b/internal/config/parse.go index 81a50f3..125e0a9 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -45,10 +45,13 @@ type tomlConfig struct { UpdateEach string `toml:"update-each" json:"updateEach,omitempty"` } `toml:"allowlist" json:"allowlist,omitempty"` Doppelganger struct { - URLs []string `toml:"urls" json:"urls,omitempty"` - Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"` - UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"` - DRS bool `toml:"drs" json:"drs,omitempty"` + URLs []string `toml:"urls" json:"urls,omitempty"` + Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"` + UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"` + DRS bool `toml:"drs" json:"drs,omitempty"` + NoiseProbeCount uint `toml:"noise-probe-count" json:"noise_probe_count,omitempty"` + NoiseCacheTTL string `toml:"noise-cache-ttl" json:"noise_cache_ttl,omitempty"` + NoiseCachePath string `toml:"noise-cache-path" json:"noise_cache_path,omitempty"` } `toml:"doppelganger" json:"doppelganger,omitempty"` } `toml:"defense" json:"defense,omitempty"` Network struct { diff --git a/mtglib/internal/tls/fake/cert_probe.go b/mtglib/internal/tls/fake/cert_probe.go new file mode 100644 index 0000000..4c92968 --- /dev/null +++ b/mtglib/internal/tls/fake/cert_probe.go @@ -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) diff --git a/mtglib/internal/tls/fake/server_side.go b/mtglib/internal/tls/fake/server_side.go index ec1c5cc..a0bf1da 100644 --- a/mtglib/internal/tls/fake/server_side.go +++ b/mtglib/internal/tls/fake/server_side.go @@ -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 } diff --git a/mtglib/internal/tls/fake/server_side_test.go b/mtglib/internal/tls/fake/server_side_test.go index c4b54a6..1a9b5f2 100644 --- a/mtglib/internal/tls/fake/server_side_test.go +++ b/mtglib/internal/tls/fake/server_side_test.go @@ -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{}) diff --git a/mtglib/proxy.go b/mtglib/proxy.go index 0e66e7c..5784f19 100644 --- a/mtglib/proxy.go +++ b/mtglib/proxy.go @@ -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, diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index cea9cad..70120e5 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -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 {