mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
1029703c2c
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Co-Authored-By: John Preston <17900494+john-preston@users.noreply.github.com>
376 lines
11 KiB
Rust
376 lines
11 KiB
Rust
use super::*;
|
|
|
|
pub(super) struct MtprotoCandidateValidation {
|
|
pub(super) proto_tag: ProtoTag,
|
|
pub(super) dc_idx: i16,
|
|
pub(super) dec_key: [u8; 32],
|
|
pub(super) dec_iv: u128,
|
|
pub(super) enc_key: [u8; 32],
|
|
pub(super) enc_iv: u128,
|
|
pub(super) decryptor: AesCtr,
|
|
pub(super) encryptor: AesCtr,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub(super) enum MtprotoModePolicy {
|
|
Configured,
|
|
Web(WebSecretMode),
|
|
}
|
|
|
|
pub(super) fn sni_hint_hash(sni: &str) -> u64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
for byte in sni.bytes() {
|
|
hasher.write_u8(byte.to_ascii_lowercase());
|
|
}
|
|
hasher.finish()
|
|
}
|
|
|
|
pub(super) fn ip_prefix_hint_key(peer_ip: IpAddr) -> u64 {
|
|
match peer_ip {
|
|
// Keep /24 granularity for IPv4 to avoid over-merging unrelated clients.
|
|
IpAddr::V4(ip) => {
|
|
let [a, b, c, _] = ip.octets();
|
|
u64::from_be_bytes([0x04, a, b, c, 0, 0, 0, 0])
|
|
}
|
|
// Keep /56 granularity for IPv6 to retain stability while limiting bucket size.
|
|
IpAddr::V6(ip) => {
|
|
let octets = ip.octets();
|
|
u64::from_be_bytes([
|
|
0x06, octets[0], octets[1], octets[2], octets[3], octets[4], octets[5], octets[6],
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn sticky_hint_get_by_ip(shared: &ProxySharedState, peer_ip: IpAddr) -> Option<u32> {
|
|
shared
|
|
.handshake
|
|
.sticky_user_by_ip
|
|
.get(&peer_ip)
|
|
.map(|entry| *entry)
|
|
}
|
|
|
|
pub(super) fn sticky_hint_get_by_ip_prefix(
|
|
shared: &ProxySharedState,
|
|
peer_ip: IpAddr,
|
|
) -> Option<u32> {
|
|
shared
|
|
.handshake
|
|
.sticky_user_by_ip_prefix
|
|
.get(&ip_prefix_hint_key(peer_ip))
|
|
.map(|entry| *entry)
|
|
}
|
|
|
|
pub(super) fn sticky_hint_get_by_sni(shared: &ProxySharedState, sni: &str) -> Option<u32> {
|
|
let key = sni_hint_hash(sni);
|
|
shared
|
|
.handshake
|
|
.sticky_user_by_sni_hash
|
|
.get(&key)
|
|
.map(|entry| *entry)
|
|
}
|
|
|
|
pub(super) fn sticky_hint_record_success_in(
|
|
shared: &ProxySharedState,
|
|
peer_ip: IpAddr,
|
|
user_id: u32,
|
|
sni: Option<&str>,
|
|
) {
|
|
if shared.handshake.sticky_user_by_ip.len() > STICKY_HINT_MAX_ENTRIES {
|
|
shared.handshake.sticky_user_by_ip.clear();
|
|
}
|
|
shared.handshake.sticky_user_by_ip.insert(peer_ip, user_id);
|
|
|
|
if shared.handshake.sticky_user_by_ip_prefix.len() > STICKY_HINT_MAX_ENTRIES {
|
|
shared.handshake.sticky_user_by_ip_prefix.clear();
|
|
}
|
|
shared
|
|
.handshake
|
|
.sticky_user_by_ip_prefix
|
|
.insert(ip_prefix_hint_key(peer_ip), user_id);
|
|
|
|
if let Some(sni) = sni {
|
|
if shared.handshake.sticky_user_by_sni_hash.len() > STICKY_HINT_MAX_ENTRIES {
|
|
shared.handshake.sticky_user_by_sni_hash.clear();
|
|
}
|
|
shared
|
|
.handshake
|
|
.sticky_user_by_sni_hash
|
|
.insert(sni_hint_hash(sni), user_id);
|
|
}
|
|
}
|
|
|
|
pub(super) fn record_recent_user_success_in(shared: &ProxySharedState, user_id: u32) {
|
|
let ring = &shared.handshake.recent_user_ring;
|
|
if ring.is_empty() {
|
|
return;
|
|
}
|
|
let seq = shared
|
|
.handshake
|
|
.recent_user_ring_seq
|
|
.fetch_add(1, Ordering::Relaxed);
|
|
let idx = (seq as usize) % ring.len();
|
|
ring[idx].store(user_id.saturating_add(1), Ordering::Relaxed);
|
|
}
|
|
|
|
pub(super) fn mark_candidate_if_new(
|
|
tried_user_ids: &mut [u32],
|
|
tried_len: &mut usize,
|
|
user_id: u32,
|
|
) -> bool {
|
|
if tried_user_ids[..*tried_len].contains(&user_id) {
|
|
return false;
|
|
}
|
|
if *tried_len < tried_user_ids.len() {
|
|
tried_user_ids[*tried_len] = user_id;
|
|
*tried_len += 1;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(super) fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> usize {
|
|
if total_users == 0 {
|
|
return 0;
|
|
}
|
|
if !overload {
|
|
return total_users;
|
|
}
|
|
let cap = if has_hint {
|
|
OVERLOAD_CANDIDATE_BUDGET_HINTED
|
|
} else {
|
|
OVERLOAD_CANDIDATE_BUDGET_UNHINTED
|
|
};
|
|
total_users.min(cap.max(1))
|
|
}
|
|
|
|
pub(super) fn validate_mtproto_secret_candidate(
|
|
handshake: &[u8; HANDSHAKE_LEN],
|
|
dec_prekey: &[u8; PREKEY_LEN],
|
|
dec_iv: u128,
|
|
enc_prekey: &[u8; PREKEY_LEN],
|
|
enc_iv: u128,
|
|
secret: &[u8; ACCESS_SECRET_BYTES],
|
|
config: &ProxyConfig,
|
|
is_tls: bool,
|
|
mode_policy: MtprotoModePolicy,
|
|
) -> Option<MtprotoCandidateValidation> {
|
|
let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
|
dec_key_input.extend_from_slice(dec_prekey);
|
|
dec_key_input.extend_from_slice(secret);
|
|
let dec_key = Zeroizing::new(sha256(&dec_key_input));
|
|
|
|
let mut decryptor = AesCtr::new(&dec_key, dec_iv);
|
|
let mut decrypted = *handshake;
|
|
decryptor.apply(&mut decrypted);
|
|
|
|
let tag_bytes: [u8; 4] = [
|
|
decrypted[PROTO_TAG_POS],
|
|
decrypted[PROTO_TAG_POS + 1],
|
|
decrypted[PROTO_TAG_POS + 2],
|
|
decrypted[PROTO_TAG_POS + 3],
|
|
];
|
|
let proto_tag = ProtoTag::from_bytes(tag_bytes)?;
|
|
if !mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, mode_policy) {
|
|
return None;
|
|
}
|
|
|
|
let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]);
|
|
|
|
let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
|
enc_key_input.extend_from_slice(enc_prekey);
|
|
enc_key_input.extend_from_slice(secret);
|
|
let enc_key = Zeroizing::new(sha256(&enc_key_input));
|
|
|
|
let encryptor = AesCtr::new(&enc_key, enc_iv);
|
|
|
|
Some(MtprotoCandidateValidation {
|
|
proto_tag,
|
|
dc_idx,
|
|
dec_key: *dec_key,
|
|
dec_iv,
|
|
enc_key: *enc_key,
|
|
enc_iv,
|
|
decryptor,
|
|
encryptor,
|
|
})
|
|
}
|
|
|
|
pub(super) fn warn_invalid_secret_once_in(
|
|
shared: &ProxySharedState,
|
|
name: &str,
|
|
reason: &str,
|
|
expected: usize,
|
|
got: Option<usize>,
|
|
) {
|
|
let key = (name.to_string(), reason.to_string());
|
|
let should_warn = match shared.handshake.invalid_secret_warned.lock() {
|
|
Ok(mut guard) => {
|
|
if !guard.contains(&key) && guard.len() >= WARNED_SECRET_MAX_ENTRIES {
|
|
false
|
|
} else {
|
|
guard.insert(key)
|
|
}
|
|
}
|
|
Err(_) => true,
|
|
};
|
|
|
|
if !should_warn {
|
|
return;
|
|
}
|
|
|
|
match got {
|
|
Some(actual) => {
|
|
warn!(
|
|
user = %name,
|
|
expected = expected,
|
|
got = actual,
|
|
"Skipping user: access secret has unexpected length"
|
|
);
|
|
}
|
|
None => {
|
|
warn!(
|
|
user = %name,
|
|
"Skipping user: access secret is not valid hex"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn decode_user_secret(
|
|
shared: &ProxySharedState,
|
|
name: &str,
|
|
secret_hex: &str,
|
|
) -> Option<Vec<u8>> {
|
|
match hex::decode(secret_hex) {
|
|
Ok(bytes) if bytes.len() == ACCESS_SECRET_BYTES => Some(bytes),
|
|
Ok(bytes) => {
|
|
warn_invalid_secret_once_in(
|
|
shared,
|
|
name,
|
|
"invalid_length",
|
|
ACCESS_SECRET_BYTES,
|
|
Some(bytes.len()),
|
|
);
|
|
None
|
|
}
|
|
Err(_) => {
|
|
warn_invalid_secret_once_in(shared, name, "invalid_hex", ACCESS_SECRET_BYTES, None);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decide whether a client-supplied proto tag is allowed given the configured
|
|
// proxy modes and the transport that carried the handshake.
|
|
//
|
|
// A common mistake is to treat `modes.tls` and `modes.secure` as interchangeable
|
|
// even though they correspond to different transport profiles: `modes.tls` is
|
|
// for the TLS-fronted (EE-TLS) path, while `modes.secure` is for direct MTProto
|
|
// over TCP (DD). Enforcing this separation prevents an attacker from using a
|
|
// TLS-capable client to bypass the operator intent for the direct MTProto mode,
|
|
// and vice versa.
|
|
pub(super) fn mode_enabled_for_proto(
|
|
config: &ProxyConfig,
|
|
proto_tag: ProtoTag,
|
|
is_tls: bool,
|
|
) -> bool {
|
|
mode_enabled_for_proto_with_policy(
|
|
config,
|
|
proto_tag,
|
|
is_tls,
|
|
MtprotoModePolicy::Configured,
|
|
)
|
|
}
|
|
|
|
fn mode_enabled_for_proto_with_policy(
|
|
config: &ProxyConfig,
|
|
proto_tag: ProtoTag,
|
|
is_tls: bool,
|
|
policy: MtprotoModePolicy,
|
|
) -> bool {
|
|
if let MtprotoModePolicy::Web(secret_mode) = policy {
|
|
return match secret_mode {
|
|
WebSecretMode::Plain => {
|
|
matches!(proto_tag, ProtoTag::Intermediate | ProtoTag::Abridged)
|
|
}
|
|
WebSecretMode::Dd => matches!(proto_tag, ProtoTag::Secure),
|
|
};
|
|
}
|
|
match proto_tag {
|
|
ProtoTag::Secure => {
|
|
if is_tls {
|
|
config.general.modes.tls
|
|
} else {
|
|
config.general.modes.secure
|
|
}
|
|
}
|
|
ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod web_mode_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn web_secret_mode_isolates_inner_protocol_tags() {
|
|
let config = ProxyConfig::default();
|
|
assert!(mode_enabled_for_proto_with_policy(
|
|
&config,
|
|
ProtoTag::Abridged,
|
|
false,
|
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
|
));
|
|
assert!(mode_enabled_for_proto_with_policy(
|
|
&config,
|
|
ProtoTag::Intermediate,
|
|
false,
|
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
|
));
|
|
assert!(!mode_enabled_for_proto_with_policy(
|
|
&config,
|
|
ProtoTag::Secure,
|
|
false,
|
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
|
));
|
|
assert!(mode_enabled_for_proto_with_policy(
|
|
&config,
|
|
ProtoTag::Secure,
|
|
false,
|
|
MtprotoModePolicy::Web(WebSecretMode::Dd),
|
|
));
|
|
assert!(!mode_enabled_for_proto_with_policy(
|
|
&config,
|
|
ProtoTag::Intermediate,
|
|
false,
|
|
MtprotoModePolicy::Web(WebSecretMode::Dd),
|
|
));
|
|
}
|
|
}
|
|
|
|
pub(super) fn decode_user_secrets_in(
|
|
shared: &ProxySharedState,
|
|
config: &ProxyConfig,
|
|
preferred_user: Option<&str>,
|
|
) -> Vec<(String, Vec<u8>)> {
|
|
let mut secrets = Vec::with_capacity(config.access.users.len());
|
|
|
|
if let Some(preferred) = preferred_user
|
|
&& let Some(secret_hex) = config.access.users.get(preferred)
|
|
&& let Some(bytes) = decode_user_secret(shared, preferred, secret_hex)
|
|
{
|
|
secrets.push((preferred.to_string(), bytes));
|
|
}
|
|
|
|
for (name, secret_hex) in &config.access.users {
|
|
if preferred_user.is_some_and(|preferred| preferred == name.as_str()) {
|
|
continue;
|
|
}
|
|
if let Some(bytes) = decode_user_secret(shared, name, secret_hex) {
|
|
secrets.push((name.clone(), bytes));
|
|
}
|
|
}
|
|
|
|
secrets
|
|
}
|