mirror of
https://github.com/seriyps/mtproto_proxy.git
synced 2026-05-13 08:46:46 +00:00
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:
parent
90cc0423ad
commit
36b30e3f5f
6 changed files with 238 additions and 4 deletions
95
README.md
95
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue