diff --git a/README.md b/README.md index 0ca0d86..ac14658 100644 --- a/README.md +++ b/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 diff --git a/src/mtp_fake_tls.erl b/src/mtp_fake_tls.erl index 2768455..b5938ed 100644 --- a/src/mtp_fake_tls.erl +++ b/src/mtp_fake_tls.erl @@ -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), + <> = + crypto:hash(sha256, [Salt, SecretHex, SniDomain]), + Derived. + parse_client_hello(<> = AllData, true -> assert_protocol(mtp_fake_tls), <> = 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) -> < @@ -683,6 +706,7 @@ hex(Bin) -> <<($W + N)>> end end || <> <= Bin>>. +-endif. unhex(Chars) -> UnHChar = fun(C) when C < $W -> C - $0; diff --git a/src/mtproto_proxy.app.src b/src/mtproto_proxy.app.src index cdca7db..bf2ff25 100644 --- a/src/mtproto_proxy.app.src +++ b/src/mtproto_proxy.app.src @@ -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 diff --git a/test/prop_mtp_fake_tls.erl b/test/prop_mtp_fake_tls.erl index 2cf2fef..43b2d22 100644 --- a/test/prop_mtp_fake_tls.erl +++ b/test/prop_mtp_fake_tls.erl @@ -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 = <>, + ?assertNotEqual(Derived, mtp_fake_tls:derive_sni_secret(Secret, OtherSni, Salt)), + %% Different salt → different secret + OtherSalt = <>, + ?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. diff --git a/test/single_dc_SUITE.erl b/test/single_dc_SUITE.erl index 7dea609..239d7e6 100644 --- a/test/single_dc_SUITE.erl +++ b/test/single_dc_SUITE.erl @@ -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).