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>
This commit is contained in:
Sergey Prokhorov 2026-04-07 03:22:47 +02:00
parent 90cc0423ad
commit 36b30e3f5f
No known key found for this signature in database
GPG key ID: 1C570244E4EF3337
6 changed files with 238 additions and 4 deletions

View file

@ -23,6 +23,8 @@ Features
See `allowed_protocols` option.
* Connection limit policies - limit number of connections by IP / tls-domain / port; IP / tls-domain
blacklists / whitelists
* Per-SNI derived secrets for personal proxies - each user gets a unique token tied to their
fake-TLS domain; base secret cannot be extracted from user links
* Multiple ports with unique secret and promo tag for each port
* Very high performance - can handle tens of thousands connections! Scales to all CPU cores.
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.
Be aware that domains table will be reset if proxy is restarted! Make sure you re-add them
when proxy restarts (eg, via [systemd hook script](https://unix.stackexchange.com/q/326181/70382)).
#### Strengthening personal proxies with per-SNI derived secrets
With the classic scheme above, the SNI domain in a user's link is the only thing that
distinguishes them — the underlying 16-byte secret is the same for everyone and is
embedded verbatim in every link, so any user who inspects their link gets the raw secret.
The only protection against credential sharing is the connection-count policy and the hope
that the user's SNI domain is long and obscure enough that no one else guesses it.
There is a tension here: for best DPI resistance, fake-TLS SNI domains should look like
real websites — short, readable names like `news.example.com` — but short readable names
are exactly the ones that are easy to guess or share.
**Per-SNI derived secrets resolve this tension.** Instead of embedding the base secret,
each user's link carries a token derived specifically for their SNI domain:
```
derived = SHA256(salt || hex(base_secret) || sni_domain)[0:16]
link = ee | derived (16 bytes) | sni_domain
```
A user who inspects their link sees only their own derived token. They cannot recover the
base secret, cannot compute tokens for other domains, and cannot construct a valid
connection using a different SNI. Combined with the domain whitelist, revoking a user
(removing their domain from the whitelist) is now cryptographically meaningful: their
derived token is unique to their domain and is worthless elsewhere.
**Enable in `sys.config`:**
```erlang
{per_sni_secrets, on},
{per_sni_secret_salt, <<"my-private-salt-change-me">>},
```
> ⚠️ **Switching to `on` invalidates all existing fake-TLS user links.** Re-issue every
> user's link after the change.
The salt is the sole true secret — an attacker who knows the base secret but not the salt
cannot compute any derived secrets.
*(HMAC-SHA256 would be cryptographically more rigorous here, but SHA-256 is secure enough
for this use case and lets every language express the derivation as a
single hash call — see the one-liners below.)*
##### Generating user links
Given your `SALT`, `SECRET` (32 lowercase hex chars from your config), and `SNI` (the
domain in the user's link):
**Bash (Linux — `sha256sum` from coreutils):**
```bash
SALT="my-private-salt-change-me"
SECRET="d0d6e111bada5511fcce9584deadbeef"
SNI="alice.example.com"
DERIVED=$(printf '%s%s%s' "$SALT" "$SECRET" "$SNI" | sha256sum | cut -c1-32)
SNI_HEX=$(printf '%s' "$SNI" | od -A n -t x1 | tr -d ' \n')
echo "ee${DERIVED}${SNI_HEX}"
```
**Bash (macOS — replace `sha256sum` with `shasum -a 256`):**
```bash
DERIVED=$(printf '%s%s%s' "$SALT" "$SECRET" "$SNI" | shasum -a 256 | cut -c1-32)
```
**Python:**
```python
import hashlib
salt, secret, sni = "my-private-salt-change-me", "d0d6e111bada5511fcce9584deadbeef", "alice.example.com"
derived = hashlib.sha256((salt + secret + sni).encode()).hexdigest()[:32]
print(f"ee{derived}{sni.encode().hex()}")
```
**Node.js:**
```javascript
const crypto = require('crypto');
const [salt, secret, sni] = ['my-private-salt-change-me', 'd0d6e111bada5511fcce9584deadbeef', 'alice.example.com'];
const derived = crypto.createHash('sha256').update(salt + secret + sni).digest('hex').slice(0, 32);
console.log('ee' + derived + Buffer.from(sni).toString('hex'));
```
**Browser JavaScript (no dependencies):**
```javascript
async function makeLink(salt, secret, sni) {
const data = new TextEncoder().encode(salt + secret + sni);
const hash = await crypto.subtle.digest('SHA-256', data);
const hex = b => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
return 'ee' + hex(new Uint8Array(hash).slice(0, 16)) + hex(new TextEncoder().encode(sni));
}
// makeLink('my-private-salt-change-me', 'd0d6...', 'alice.example.com').then(console.log)
```
### IPv6
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):
<...>
```
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.
### Tune resource consumption
If your server have low amount of RAM, try to set

View file

@ -14,6 +14,7 @@
-export([format_secret_base64/2,
format_secret_hex/2]).
-export([from_client_hello/2,
derive_sni_secret/3,
parse_sni/1,
new/0,
try_decode_packet/2,
@ -159,6 +160,24 @@ parse_sni(Data) ->
{error, bad_hello}
end.
%% Derive a per-SNI 16-byte secret from the base secret, SNI domain and a salt.
%% Derivation: SHA256(salt || hex32(base_secret) || sni_domain)[0:16]
%%
%% The salt is the sole true secret keep it private and back it up alongside
%% the base secret. The base_secret is included in the message for instance-specific
%% binding (defense-in-depth if the salt ever leaks).
%%
%% Using hex32(base_secret) rather than raw bytes makes the derivation reproducible
%% with a single SHA-256 call in any language without binary manipulation:
%% sha256(salt + secret_hex + sni)[0:16]
-spec derive_sni_secret(BaseSecret :: binary(), SniDomain :: binary(), Salt :: binary())
-> binary().
derive_sni_secret(BaseSecret, SniDomain, Salt) when byte_size(BaseSecret) == 16 ->
SecretHex = mtp_handler:hex(BaseSecret),
<<Derived:16/binary, _/binary>> =
crypto:hash(sha256, [Salt, SecretHex, SniDomain]),
Derived.
parse_client_hello(<<?TLS_REC_HANDSHAKE, ?TLS_10_VERSION, TlsFrameLen:?u16, %Frame
?TLS_TAG_CLI_HELLO, HelloLen:?u24, ?TLS_12_VERSION,

View file

@ -365,13 +365,14 @@ parse_upstream_data(<<?TLS_START, _/binary>> = AllData,
true ->
assert_protocol(mtp_fake_tls),
<<Data:FullPacketSize/binary, Tail/binary>> = AllData,
{ok, Response, Meta, TlsCodec} = mtp_fake_tls:from_client_hello(Data, Secret),
EffSecret = effective_secret(Data, Secret),
{ok, Response, Meta, TlsCodec} = mtp_fake_tls:from_client_hello(Data, EffSecret),
maybe_check_replay_tls(Meta),
check_tls_policy(Listener, Ip, Meta),
Codec1 = mtp_codec:replace(tls, true, TlsCodec, Codec0),
Codec = mtp_codec:push_back(tls, Tail, Codec1),
ok = up_send_raw(Response, S), %FIXME: if this send fail, we will get counter policy leak
{ok, S#state{codec = Codec, stage = init,
{ok, S#state{codec = Codec, stage = init, secret = EffSecret,
policy_state = {ok, maps:get(sni_domain, Meta, undefined)}}};
false ->
%% Received only part of the ClientHello push it back into the codec
@ -528,6 +529,25 @@ maybe_check_replay_tls(#{client_digest := Digest} = Meta) ->
ok
end.
%% Select the secret to use for fake-TLS ClientHello validation.
%% When per_sni_secrets=on, derive a domain-specific 16-byte secret so that each
%% SNI domain gets a unique token users cannot recover the base secret from their link.
effective_secret(Data, Secret) ->
case application:get_env(?APP, per_sni_secrets, off) of
off ->
Secret;
on ->
Salt = application:get_env(?APP, per_sni_secret_salt,
<<"mtproto-proxy-per-sni-v1">>),
case mtp_fake_tls:parse_sni(Data) of
{ok, Sni} ->
mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt);
{error, Reason} ->
%% No SNI not a valid fake-TLS MTP connection; fail fast.
error({protocol_error, tls_invalid_digest, Reason})
end
end.
do_front(SniDomain, Config, Data, Ip, Listener,
#state{sock = Sock, transport = Transport} = S) ->
try
@ -675,6 +695,9 @@ sum_binary(BinInfo) ->
Sum + (Size / RefC)
end, 0, BinInfo)).
-if(?OTP_RELEASE >= 26).
hex(Bin) -> binary:encode_hex(Bin, lowercase).
-else.
hex(Bin) ->
<<begin
if N < 10 ->
@ -683,6 +706,7 @@ hex(Bin) ->
<<($W + N)>>
end
end || <<N:4>> <= Bin>>.
-endif.
unhex(Chars) ->
UnHChar = fun(C) when C < $W -> C - $0;

View file

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

View file

@ -6,7 +6,8 @@
-export([prop_codec_small/1, prop_codec_big/1, prop_stream/1,
prop_variable_length_hello/1,
prop_parse_sni_valid/1,
prop_parse_sni_garbage/1]).
prop_parse_sni_garbage/1,
prop_derive_sni_secret/1]).
prop_codec_small(doc) ->
"Tests that any binary below 65535 bytes can be encoded and decoded back as single frame".
@ -124,3 +125,31 @@ parse_sni_garbage(Bin) ->
Result = mtp_fake_tls:parse_sni(Bin),
?assert(Result =:= {error, bad_hello} orelse Result =:= {error, no_sni}),
true.
prop_derive_sni_secret(doc) ->
"derive_sni_secret/3 produces a 16-byte secret that is stable and domain/salt/secret-specific".
prop_derive_sni_secret() ->
?FORALL({Secret, Sni, Salt},
{proper_types:binary(16),
proper_types:non_empty(proper_types:binary()),
proper_types:non_empty(proper_types:binary())},
derive_sni_secret(Secret, Sni, Salt)).
derive_sni_secret(Secret, Sni, Salt) ->
Derived = mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt),
%% Always 16 bytes
?assertEqual(16, byte_size(Derived)),
%% Deterministic
?assertEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, Sni, Salt)),
%% Different SNI different secret
OtherSni = <<Sni/binary, "_other">>,
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, OtherSni, Salt)),
%% Different salt different secret
OtherSalt = <<Salt/binary, "_other">>,
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, Sni, OtherSalt)),
%% Different base secret different derived secret
OtherSecret = crypto:strong_rand_bytes(16),
?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(OtherSecret, Sni, Salt)),
true.

View file

@ -24,7 +24,9 @@
domain_fronting_off_case/1,
domain_fronting_blacklist_case/1,
domain_fronting_fragmented_case/1,
domain_fronting_replay_case/1
domain_fronting_replay_case/1,
per_sni_secrets_on_case/1,
per_sni_secrets_wrong_secret_case/1
]).
-export([set_env/2,
@ -685,6 +687,54 @@ domain_fronting_replay_case(Cfg) when is_list(Cfg) ->
gen_tcp:close(FrontSock),
gen_tcp:close(Sock2).
%% @doc per_sni_secrets=on: client connecting with a correctly-derived secret succeeds.
per_sni_secrets_on_case({pre, Cfg}) ->
Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg),
set_env([{per_sni_secrets, on}], Cfg1);
per_sni_secrets_on_case({post, Cfg}) ->
stop_single(Cfg),
reset_env(Cfg);
per_sni_secrets_on_case(Cfg) when is_list(Cfg) ->
DcId = ?config(dc_id, Cfg),
Host = ?config(mtp_host, Cfg),
Port = ?config(mtp_port, Cfg),
RawSecret = mtp_handler:unhex(?config(mtp_secret, Cfg)),
Domain = <<"example.com">>,
Salt = application:get_env(mtproto_proxy, per_sni_secret_salt,
<<"mtproto-proxy-per-sni-v1">>),
DerivedSecret = mtp_fake_tls:derive_sni_secret(RawSecret, Domain, Salt),
DerivedSecretHex = mtp_handler:hex(DerivedSecret),
Cli0 = mtp_test_client:connect(Host, Port, DerivedSecretHex, DcId,
{mtp_fake_tls, Domain}),
Cli1 = ping(Cli0),
?assertEqual(
1, mtp_test_metric:get_tags(
count, [?APP, protocol_ok, total], [?FUNCTION_NAME, mtp_secure_fake_tls])),
ok = mtp_test_client:close(Cli1).
%% @doc per_sni_secrets=on: client connecting with the raw base secret is rejected.
per_sni_secrets_wrong_secret_case({pre, Cfg}) ->
Cfg1 = setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg),
set_env([{per_sni_secrets, on}], Cfg1);
per_sni_secrets_wrong_secret_case({post, Cfg}) ->
stop_single(Cfg),
reset_env(Cfg);
per_sni_secrets_wrong_secret_case(Cfg) when is_list(Cfg) ->
DcId = ?config(dc_id, Cfg),
Host = ?config(mtp_host, Cfg),
Port = ?config(mtp_port, Cfg),
Secret = ?config(mtp_secret, Cfg),
%% Using the raw base secret (not derived) must be rejected.
?assertError({badmatch, {error, closed}},
begin
Cli0 = mtp_test_client:connect(Host, Port, Secret, DcId,
{mtp_fake_tls, <<"example.com">>}),
ping(Cli0)
end),
?assertEqual(
1, mtp_test_metric:get_tags(
count, [?APP, protocol_error, total], [?FUNCTION_NAME, tls_invalid_digest])).
setup_single(Name, MtpPort, DcCfg0, Cfg) ->
setup_single(Name, "127.0.0.1", MtpPort, DcCfg0, Cfg).