Skip to content

Commit 36b30e3

Browse files
seriypsCopilot
andcommitted
Add per-SNI derived secrets feature
Each fake-TLS SNI domain gets a unique 16-byte secret derived from the SNI, base secret and a private salt, so users cannot extract the base secret from their proxy link or forge tokens by guessing other domains. Derivation: SHA256(salt || hex(base_secret) || sni_domain)[0:16] Salt-first ordering ensures security even when base_secret is known (e.g. when non-fake-TLS protocols are enabled on the same port). New config options (default: off): {per_sni_secrets, off | on} {per_sni_secret_salt, <<"..ascii..">>} Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 90cc042 commit 36b30e3

6 files changed

Lines changed: 238 additions & 4 deletions

File tree

README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Features
2323
See `allowed_protocols` option.
2424
* Connection limit policies - limit number of connections by IP / tls-domain / port; IP / tls-domain
2525
blacklists / whitelists
26+
* Per-SNI derived secrets for personal proxies - each user gets a unique token tied to their
27+
fake-TLS domain; base secret cannot be extracted from user links
2628
* Multiple ports with unique secret and promo tag for each port
2729
* Very high performance - can handle tens of thousands connections! Scales to all CPU cores.
2830
1Gbps, 90k connections on 4-core/8Gb RAM cloud server.
@@ -621,6 +623,97 @@ And then use https://seriyps.com/mtpgen.html to generate unique link for them.
621623
Be aware that domains table will be reset if proxy is restarted! Make sure you re-add them
622624
when proxy restarts (eg, via [systemd hook script](https://unix.stackexchange.com/q/326181/70382)).
623625
626+
#### Strengthening personal proxies with per-SNI derived secrets
627+
628+
With the classic scheme above, the SNI domain in a user's link is the only thing that
629+
distinguishes themthe underlying 16-byte secret is the same for everyone and is
630+
embedded verbatim in every link, so any user who inspects their link gets the raw secret.
631+
The only protection against credential sharing is the connection-count policy and the hope
632+
that the user's SNI domain is long and obscure enough that no one else guesses it.
633+
634+
There is a tension here: for best DPI resistance, fake-TLS SNI domains should look like
635+
real websites — short, readable names like `news.example.com` — but short readable names
636+
are exactly the ones that are easy to guess or share.
637+
638+
**Per-SNI derived secrets resolve this tension.** Instead of embedding the base secret,
639+
each user's link carries a token derived specifically for their SNI domain:
640+
641+
```
642+
derived = SHA256(salt || hex(base_secret) || sni_domain)[0:16]
643+
link = ee | derived (16 bytes) | sni_domain
644+
```
645+
646+
A user who inspects their link sees only their own derived token. They cannot recover the
647+
base secret, cannot compute tokens for other domains, and cannot construct a valid
648+
connection using a different SNI. Combined with the domain whitelist, revoking a user
649+
(removing their domain from the whitelist) is now cryptographically meaningful: their
650+
derived token is unique to their domain and is worthless elsewhere.
651+
652+
**Enable in `sys.config`:**
653+
654+
```erlang
655+
{per_sni_secrets, on},
656+
{per_sni_secret_salt, <<"my-private-salt-change-me">>},
657+
```
658+
659+
> ⚠️ **Switching to `on` invalidates all existing fake-TLS user links.** Re-issue every
660+
> user's link after the change.
661+
662+
The salt is the sole true secret — an attacker who knows the base secret but not the salt
663+
cannot compute any derived secrets.
664+
665+
*(HMAC-SHA256 would be cryptographically more rigorous here, but SHA-256 is secure enough
666+
for this use case and lets every language express the derivation as a
667+
single hash call — see the one-liners below.)*
668+
669+
##### Generating user links
670+
671+
Given your `SALT`, `SECRET` (32 lowercase hex chars from your config), and `SNI` (the
672+
domain in the user's link):
673+
674+
**Bash (Linux — `sha256sum` from coreutils):**
675+
```bash
676+
SALT="my-private-salt-change-me"
677+
SECRET="d0d6e111bada5511fcce9584deadbeef"
678+
SNI="alice.example.com"
679+
680+
DERIVED=$(printf '%s%s%s' "$SALT" "$SECRET" "$SNI" | sha256sum | cut -c1-32)
681+
SNI_HEX=$(printf '%s' "$SNI" | od -A n -t x1 | tr -d ' \n')
682+
echo "ee${DERIVED}${SNI_HEX}"
683+
```
684+
685+
**Bash (macOSreplace `sha256sum` with `shasum -a 256`):**
686+
```bash
687+
DERIVED=$(printf '%s%s%s' "$SALT" "$SECRET" "$SNI" | shasum -a 256 | cut -c1-32)
688+
```
689+
690+
**Python:**
691+
```python
692+
import hashlib
693+
salt, secret, sni = "my-private-salt-change-me", "d0d6e111bada5511fcce9584deadbeef", "alice.example.com"
694+
derived = hashlib.sha256((salt + secret + sni).encode()).hexdigest()[:32]
695+
print(f"ee{derived}{sni.encode().hex()}")
696+
```
697+
698+
**Node.js:**
699+
```javascript
700+
const crypto = require('crypto');
701+
const [salt, secret, sni] = ['my-private-salt-change-me', 'd0d6e111bada5511fcce9584deadbeef', 'alice.example.com'];
702+
const derived = crypto.createHash('sha256').update(salt + secret + sni).digest('hex').slice(0, 32);
703+
console.log('ee' + derived + Buffer.from(sni).toString('hex'));
704+
```
705+
706+
**Browser JavaScript (no dependencies):**
707+
```javascript
708+
async function makeLink(salt, secret, sni) {
709+
const data = new TextEncoder().encode(salt + secret + sni);
710+
const hash = await crypto.subtle.digest('SHA-256', data);
711+
const hex = b => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
712+
return 'ee' + hex(new Uint8Array(hash).slice(0, 16)) + hex(new TextEncoder().encode(sni));
713+
}
714+
// makeLink('my-private-salt-change-me', 'd0d6...', 'alice.example.com').then(console.log)
715+
```
716+
624717
### IPv6
625718

626719
Currently proxy only supports client connections via IPv6, but can only connect to Telegram servers
@@ -653,6 +746,8 @@ different `listen_ip` (one v4 and one v6):
653746
<...>
654747
```
655748

749+
Keep in mind that `listen_ip => "::"` would listen both on IPv6 and IPv4(!!) addresses (in IPv6 to v4 compat mode). If you need separate listeners, specify full IPv6 address explicitly.
750+
656751
### Tune resource consumption
657752

658753
If your server have low amount of RAM, try to set

src/mtp_fake_tls.erl

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
-export([format_secret_base64/2,
1515
format_secret_hex/2]).
1616
-export([from_client_hello/2,
17+
derive_sni_secret/3,
1718
parse_sni/1,
1819
new/0,
1920
try_decode_packet/2,
@@ -159,6 +160,24 @@ parse_sni(Data) ->
159160
{error, bad_hello}
160161
end.
161162

163+
%% Derive a per-SNI 16-byte secret from the base secret, SNI domain and a salt.
164+
%% Derivation: SHA256(salt || hex32(base_secret) || sni_domain)[0:16]
165+
%%
166+
%% The salt is the sole true secret — keep it private and back it up alongside
167+
%% the base secret. The base_secret is included in the message for instance-specific
168+
%% binding (defense-in-depth if the salt ever leaks).
169+
%%
170+
%% Using hex32(base_secret) rather than raw bytes makes the derivation reproducible
171+
%% with a single SHA-256 call in any language without binary manipulation:
172+
%% sha256(salt + secret_hex + sni)[0:16]
173+
-spec derive_sni_secret(BaseSecret :: binary(), SniDomain :: binary(), Salt :: binary())
174+
-> binary().
175+
derive_sni_secret(BaseSecret, SniDomain, Salt) when byte_size(BaseSecret) == 16 ->
176+
SecretHex = mtp_handler:hex(BaseSecret),
177+
<<Derived:16/binary, _/binary>> =
178+
crypto:hash(sha256, [Salt, SecretHex, SniDomain]),
179+
Derived.
180+
162181

163182
parse_client_hello(<<?TLS_REC_HANDSHAKE, ?TLS_10_VERSION, TlsFrameLen:?u16, %Frame
164183
?TLS_TAG_CLI_HELLO, HelloLen:?u24, ?TLS_12_VERSION,

src/mtp_handler.erl

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,13 +365,14 @@ parse_upstream_data(<<?TLS_START, _/binary>> = AllData,
365365
true ->
366366
assert_protocol(mtp_fake_tls),
367367
<<Data:FullPacketSize/binary, Tail/binary>> = AllData,
368-
{ok, Response, Meta, TlsCodec} = mtp_fake_tls:from_client_hello(Data, Secret),
368+
EffSecret = effective_secret(Data, Secret),
369+
{ok, Response, Meta, TlsCodec} = mtp_fake_tls:from_client_hello(Data, EffSecret),
369370
maybe_check_replay_tls(Meta),
370371
check_tls_policy(Listener, Ip, Meta),
371372
Codec1 = mtp_codec:replace(tls, true, TlsCodec, Codec0),
372373
Codec = mtp_codec:push_back(tls, Tail, Codec1),
373374
ok = up_send_raw(Response, S), %FIXME: if this send fail, we will get counter policy leak
374-
{ok, S#state{codec = Codec, stage = init,
375+
{ok, S#state{codec = Codec, stage = init, secret = EffSecret,
375376
policy_state = {ok, maps:get(sni_domain, Meta, undefined)}}};
376377
false ->
377378
%% Received only part of the ClientHello — push it back into the codec
@@ -528,6 +529,25 @@ maybe_check_replay_tls(#{client_digest := Digest} = Meta) ->
528529
ok
529530
end.
530531

532+
%% Select the secret to use for fake-TLS ClientHello validation.
533+
%% When per_sni_secrets=on, derive a domain-specific 16-byte secret so that each
534+
%% SNI domain gets a unique token — users cannot recover the base secret from their link.
535+
effective_secret(Data, Secret) ->
536+
case application:get_env(?APP, per_sni_secrets, off) of
537+
off ->
538+
Secret;
539+
on ->
540+
Salt = application:get_env(?APP, per_sni_secret_salt,
541+
<<"mtproto-proxy-per-sni-v1">>),
542+
case mtp_fake_tls:parse_sni(Data) of
543+
{ok, Sni} ->
544+
mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt);
545+
{error, Reason} ->
546+
%% No SNI — not a valid fake-TLS MTP connection; fail fast.
547+
error({protocol_error, tls_invalid_digest, Reason})
548+
end
549+
end.
550+
531551
do_front(SniDomain, Config, Data, Ip, Listener,
532552
#state{sock = Sock, transport = Transport} = S) ->
533553
try
@@ -675,6 +695,9 @@ sum_binary(BinInfo) ->
675695
Sum + (Size / RefC)
676696
end, 0, BinInfo)).
677697

698+
-if(?OTP_RELEASE >= 26).
699+
hex(Bin) -> binary:encode_hex(Bin, lowercase).
700+
-else.
678701
hex(Bin) ->
679702
<<begin
680703
if N < 10 ->
@@ -683,6 +706,7 @@ hex(Bin) ->
683706
<<($W + N)>>
684707
end
685708
end || <<N:4>> <= Bin>>.
709+
-endif.
686710

687711
unhex(Chars) ->
688712
UnHChar = fun(C) when C < $W -> C - $0;

src/mtproto_proxy.app.src

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@
4848
%% {max_connections, [port_name, tls_domain], 15}
4949
%% ]},
5050

51+
%% Per-SNI derived secrets. When enabled, each fake-TLS SNI domain gets a unique
52+
%% 16-byte secret derived from the base secret and the salt. Users can no longer
53+
%% extract the base secret from their link or forge secrets for other SNI domains.
54+
%% See README.md for details.
55+
%%
56+
%% off - (default) base secret used verbatim; all existing links work unchanged.
57+
%% on - derived secrets required; old base-secret links are rejected.
58+
%% Re-issue all fake-TLS user links after switching to `on`.
59+
{per_sni_secrets, off},
60+
61+
%% Salt used in per-SNI secret derivation (replace with unique secret value!):
62+
%% SHA256(salt || hex(base_secret) || sni_domain)[0:16]
63+
%% The salt is the sole secret protecting derived secrets — back it up alongside
64+
%% the base secret from `ports`. Losing the salt makes all derived secrets
65+
%% unrecoverable (users will need new links).
66+
{per_sni_secret_salt, <<"mtproto-proxy-per-sni-v1">>},
67+
5168
%% Close connection if it failed to perform handshake in this many seconds
5269
{init_timeout_sec, 60},
5370
%% Switch client to memory-saving mode after this many seconds of inactivity

test/prop_mtp_fake_tls.erl

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
-export([prop_codec_small/1, prop_codec_big/1, prop_stream/1,
77
prop_variable_length_hello/1,
88
prop_parse_sni_valid/1,
9-
prop_parse_sni_garbage/1]).
9+
prop_parse_sni_garbage/1,
10+
prop_derive_sni_secret/1]).
1011

1112
prop_codec_small(doc) ->
1213
"Tests that any binary below 65535 bytes can be encoded and decoded back as single frame".
@@ -124,3 +125,31 @@ parse_sni_garbage(Bin) ->
124125
Result = mtp_fake_tls:parse_sni(Bin),
125126
?assert(Result =:= {error, bad_hello} orelse Result =:= {error, no_sni}),
126127
true.
128+
129+
130+
prop_derive_sni_secret(doc) ->
131+
"derive_sni_secret/3 produces a 16-byte secret that is stable and domain/salt/secret-specific".
132+
133+
prop_derive_sni_secret() ->
134+
?FORALL({Secret, Sni, Salt},
135+
{proper_types:binary(16),
136+
proper_types:non_empty(proper_types:binary()),
137+
proper_types:non_empty(proper_types:binary())},
138+
derive_sni_secret(Secret, Sni, Salt)).
139+
140+
derive_sni_secret(Secret, Sni, Salt) ->
141+
Derived = mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt),
142+
%% Always 16 bytes
143+
?assertEqual(16, byte_size(Derived)),
144+
%% Deterministic
145+
?assertEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt)),
146+
%% Different SNI → different secret
147+
OtherSni = <<Sni/binary, "_other">>,
148+
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, OtherSni, Salt)),
149+
%% Different salt → different secret
150+
OtherSalt = <<Salt/binary, "_other">>,
151+
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, Sni, OtherSalt)),
152+
%% Different base secret → different derived secret
153+
OtherSecret = crypto:strong_rand_bytes(16),
154+
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(OtherSecret, Sni, Salt)),
155+
true.

test/single_dc_SUITE.erl

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
domain_fronting_off_case/1,
2525
domain_fronting_blacklist_case/1,
2626
domain_fronting_fragmented_case/1,
27-
domain_fronting_replay_case/1
27+
domain_fronting_replay_case/1,
28+
per_sni_secrets_on_case/1,
29+
per_sni_secrets_wrong_secret_case/1
2830
]).
2931

3032
-export([set_env/2,
@@ -685,6 +687,54 @@ domain_fronting_replay_case(Cfg) when is_list(Cfg) ->
685687
gen_tcp:close(FrontSock),
686688
gen_tcp:close(Sock2).
687689

690+
%% @doc per_sni_secrets=on: client connecting with a correctly-derived secret succeeds.
691+
per_sni_secrets_on_case({pre, Cfg}) ->
692+
Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg),
693+
set_env([{per_sni_secrets, on}], Cfg1);
694+
per_sni_secrets_on_case({post, Cfg}) ->
695+
stop_single(Cfg),
696+
reset_env(Cfg);
697+
per_sni_secrets_on_case(Cfg) when is_list(Cfg) ->
698+
DcId = ?config(dc_id, Cfg),
699+
Host = ?config(mtp_host, Cfg),
700+
Port = ?config(mtp_port, Cfg),
701+
RawSecret = mtp_handler:unhex(?config(mtp_secret, Cfg)),
702+
Domain = <<"example.com">>,
703+
Salt = application:get_env(mtproto_proxy, per_sni_secret_salt,
704+
<<"mtproto-proxy-per-sni-v1">>),
705+
DerivedSecret = mtp_fake_tls:derive_sni_secret(RawSecret, Domain, Salt),
706+
DerivedSecretHex = mtp_handler:hex(DerivedSecret),
707+
Cli0 = mtp_test_client:connect(Host, Port, DerivedSecretHex, DcId,
708+
{mtp_fake_tls, Domain}),
709+
Cli1 = ping(Cli0),
710+
?assertEqual(
711+
1, mtp_test_metric:get_tags(
712+
count, [?APP, protocol_ok, total], [?FUNCTION_NAME, mtp_secure_fake_tls])),
713+
ok = mtp_test_client:close(Cli1).
714+
715+
%% @doc per_sni_secrets=on: client connecting with the raw base secret is rejected.
716+
per_sni_secrets_wrong_secret_case({pre, Cfg}) ->
717+
Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg),
718+
set_env([{per_sni_secrets, on}], Cfg1);
719+
per_sni_secrets_wrong_secret_case({post, Cfg}) ->
720+
stop_single(Cfg),
721+
reset_env(Cfg);
722+
per_sni_secrets_wrong_secret_case(Cfg) when is_list(Cfg) ->
723+
DcId = ?config(dc_id, Cfg),
724+
Host = ?config(mtp_host, Cfg),
725+
Port = ?config(mtp_port, Cfg),
726+
Secret = ?config(mtp_secret, Cfg),
727+
%% Using the raw base secret (not derived) must be rejected.
728+
?assertError({badmatch, {error, closed}},
729+
begin
730+
Cli0 = mtp_test_client:connect(Host, Port, Secret, DcId,
731+
{mtp_fake_tls, <<"example.com">>}),
732+
ping(Cli0)
733+
end),
734+
?assertEqual(
735+
1, mtp_test_metric:get_tags(
736+
count, [?APP, protocol_error, total], [?FUNCTION_NAME, tls_invalid_digest])).
737+
688738
setup_single(Name, MtpPort, DcCfg0, Cfg) ->
689739
setup_single(Name, "127.0.0.1", MtpPort, DcCfg0, Cfg).
690740

0 commit comments

Comments
 (0)