use std::cmp::Ordering;
use std::fmt;
use crate::Result;
use crate::api::{ApiProfile, LAYER, Versions};
#[must_use]
pub fn tz_offset(utc_offset_seconds: i32) -> f64 {
const DAY: i32 = 24 * 3600;
const LOW: i32 = -12 * 3600;
const HIGH: i32 = 14 * 3600;
let mut sliced = utc_offset_seconds;
if sliced < LOW {
sliced = LOW + (sliced - LOW).rem_euclid(DAY);
}
if sliced > HIGH {
sliced = HIGH - (HIGH - sliced).rem_euclid(DAY);
}
let rounded = (sliced.abs() + 450) / 900 * 900;
f64::from(if sliced < 0 { -rounded } else { rounded })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StrictMode {
Off,
Warn,
Strict,
}
impl StrictMode {
pub const ALL: [Self; 3] = [Self::Off, Self::Warn, Self::Strict];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Warn => "warn",
Self::Strict => "strict",
}
}
#[must_use]
pub fn from_value(value: &str) -> Option<Self> {
Self::ALL.into_iter().find(|mode| mode.as_str() == value)
}
#[must_use]
pub fn severity(self) -> Severity {
match self {
Self::Off => Severity::Note,
Self::Warn => Severity::Warning,
Self::Strict => Severity::Error,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Note,
Warning,
Error,
}
impl Severity {
pub const ALL: [Self; 3] = [Self::Note, Self::Warning, Self::Error];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Note => "note",
Self::Warning => "warning",
Self::Error => "error",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|severity| severity.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IssueKind {
UnknownLangPack,
ShortLangCode,
MissingRegion,
EmptyDeviceModel,
EmptySystemVersion,
EmptyAppVersion,
ApiIdMismatch,
AppVersionMajor,
AndroidSdkRange,
}
impl IssueKind {
pub const ALL: [Self; 9] = [
Self::UnknownLangPack,
Self::ShortLangCode,
Self::MissingRegion,
Self::EmptyDeviceModel,
Self::EmptySystemVersion,
Self::EmptyAppVersion,
Self::ApiIdMismatch,
Self::AppVersionMajor,
Self::AndroidSdkRange,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::UnknownLangPack => "UnknownLangPack",
Self::ShortLangCode => "ShortLangCode",
Self::MissingRegion => "MissingRegion",
Self::EmptyDeviceModel => "EmptyDeviceModel",
Self::EmptySystemVersion => "EmptySystemVersion",
Self::EmptyAppVersion => "EmptyAppVersion",
Self::ApiIdMismatch => "ApiIdMismatch",
Self::AppVersionMajor => "AppVersionMajor",
Self::AndroidSdkRange => "AndroidSdkRange",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.name() == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Issue {
pub kind: IssueKind,
pub severity: Severity,
pub message: String,
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
#[must_use]
pub fn issues_message(issues: &[Issue]) -> String {
let mut message = String::from("initConnection fingerprint issues:");
for issue in issues {
message.push_str("\n - ");
message.push_str(&issue.message);
}
message
}
const VALID_LANG_PACKS: [&str; 5] = ["", "android", "ios", "macos", "tdesktop"];
fn official_api_id(lang_pack: &str) -> Option<i32> {
match lang_pack {
"tdesktop" => Some(2040),
"android" => Some(6),
"ios" => Some(10840),
"macos" => Some(2834),
_ => None,
}
}
#[must_use]
pub fn validate_init_params(
profile: &ApiProfile,
versions: &Versions,
mode: StrictMode,
) -> Vec<Issue> {
validate_raw_init_params(&RawInitParams::from_profile(profile), versions, mode)
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum RawApiId<'a> {
Int(i64),
Float(f64),
Bool(bool),
Text(&'a str),
#[default]
Missing,
}
impl RawApiId<'_> {
#[must_use]
pub fn equals_id(&self, id: i32) -> bool {
match *self {
Self::Int(value) => value == i64::from(id),
Self::Float(value) => {
matches!(value.partial_cmp(&f64::from(id)), Some(Ordering::Equal))
}
Self::Bool(value) => i32::from(value) == id,
Self::Text(_) | Self::Missing => false,
}
}
}
impl fmt::Display for RawApiId<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Int(value) => write!(f, "{value}"),
Self::Float(value) => f.write_str(&py_float_repr(value)),
Self::Bool(true) => f.write_str("True"),
Self::Bool(false) => f.write_str("False"),
Self::Text(text) => f.write_str(text),
Self::Missing => f.write_str("None"),
}
}
}
fn py_float_repr(value: f64) -> String {
if value.is_nan() {
return "nan".to_owned();
}
let sign = if value.is_sign_negative() { "-" } else { "" };
if value.is_infinite() {
return format!("{sign}inf");
}
let scientific = format!("{:e}", value.abs());
let (mantissa, exponent) = scientific
.split_once('e')
.unwrap_or((scientific.as_str(), "0"));
let exponent: i32 = exponent.parse().unwrap_or(0);
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
if !(-4..16).contains(&exponent) {
let (first, rest) = digits.split_at(1);
let fraction = if rest.is_empty() {
String::new()
} else {
format!(".{rest}")
};
let exponent_sign = if exponent < 0 { '-' } else { '+' };
return format!(
"{sign}{first}{fraction}e{exponent_sign}{:02}",
exponent.unsigned_abs()
);
}
let point = usize::try_from(exponent + 1).unwrap_or(0);
if exponent < 0 {
let zeros = "0".repeat(usize::try_from(-exponent - 1).unwrap_or(0));
return format!("{sign}0.{zeros}{digits}");
}
if digits.len() <= point {
let zeros = "0".repeat(point - digits.len());
format!("{sign}{digits}{zeros}.0")
} else {
let (whole, fraction) = digits.split_at(point);
format!("{sign}{whole}.{fraction}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct RawInitParams<'a> {
pub api_id: RawApiId<'a>,
pub device_model: Option<&'a str>,
pub system_version: Option<&'a str>,
pub app_version: Option<&'a str>,
pub system_lang_code: Option<&'a str>,
pub lang_pack: Option<&'a str>,
pub lang_code: Option<&'a str>,
}
impl<'a> RawInitParams<'a> {
#[must_use]
pub fn from_profile(profile: &'a ApiProfile) -> Self {
Self {
api_id: RawApiId::Int(i64::from(profile.api_id)),
device_model: Some(&profile.device_model),
system_version: Some(&profile.system_version),
app_version: Some(&profile.app_version),
system_lang_code: Some(&profile.system_lang_code),
lang_pack: Some(&profile.lang_pack),
lang_code: Some(&profile.lang_code),
}
}
}
#[must_use]
pub fn validate_raw_init_params(
params: &RawInitParams<'_>,
versions: &Versions,
mode: StrictMode,
) -> Vec<Issue> {
let severity = mode.severity();
let mut issues = Vec::new();
let mut push = |kind, message| {
issues.push(Issue {
kind,
severity,
message,
});
};
let lang_pack = params.lang_pack.unwrap_or("None");
let lang_code = params.lang_code.unwrap_or("");
let system_lang_code = params.system_lang_code.unwrap_or("");
let system_version = params.system_version.unwrap_or("");
let app_version = params.app_version.unwrap_or("");
if !VALID_LANG_PACKS.contains(&lang_pack) {
push(
IssueKind::UnknownLangPack,
format!(
"lang_pack '{lang_pack}' is not a known official value. \
Expected one of: ['', 'android', 'ios', 'macos', 'tdesktop']"
),
);
}
if lang_code.chars().count() < 2 {
push(
IssueKind::ShortLangCode,
format!(
"lang_code '{}' looks invalid (too short)",
params.lang_code.unwrap_or("None")
),
);
}
if matches!(lang_pack, "android" | "ios") && !system_lang_code.contains('-') {
push(
IssueKind::MissingRegion,
format!(
"system_lang_code '{system_lang_code}' should include a region code \
(e.g. 'en-US') for {lang_pack} clients"
),
);
}
if params.device_model.unwrap_or("").is_empty() {
push(
IssueKind::EmptyDeviceModel,
"device_model is empty".to_owned(),
);
}
if system_version.is_empty() {
push(
IssueKind::EmptySystemVersion,
"system_version is empty".to_owned(),
);
}
if app_version.is_empty() {
push(
IssueKind::EmptyAppVersion,
"app_version is empty".to_owned(),
);
}
if mode == StrictMode::Strict {
if let Some(expected) = official_api_id(lang_pack)
&& !params.api_id.equals_id(expected)
{
push(
IssueKind::ApiIdMismatch,
format!(
"api_id {} does not match lang_pack '{lang_pack}' (expected {expected})",
params.api_id
),
);
}
version_issues(
lang_pack,
app_version,
system_version,
versions,
severity,
&mut issues,
);
}
issues
}
#[must_use]
pub fn check_versions(profile: &ApiProfile, versions: &Versions) -> Vec<Issue> {
let mut issues = Vec::new();
version_issues(
&profile.lang_pack,
&profile.app_version,
&profile.system_version,
versions,
Severity::Error,
&mut issues,
);
issues
}
fn version_issues(
lang_pack: &str,
app_version: &str,
system_version: &str,
versions: &Versions,
severity: Severity,
issues: &mut Vec<Issue>,
) {
let known = match lang_pack {
"android" => Some((&versions.android_app_version, "Android")),
"ios" => Some((&versions.ios_app_version, "iOS")),
"tdesktop" => Some((&versions.desktop_app_version, "Desktop")),
"macos" => Some((&versions.macos_app_version, "macOS")),
_ => None,
};
if let Some((known, label)) = known {
let major = known
.split_once('.')
.map_or(known.as_str(), |(major, _)| major);
if !app_version.starts_with(major) {
issues.push(Issue {
kind: IssueKind::AppVersionMajor,
severity,
message: format!(
"{label} app_version '{app_version}' major does not match latest known '{known}'"
),
});
}
}
if lang_pack == "android" {
let sdk = if system_version.contains("SDK") {
PyInt::parse(&system_version.replace("SDK ", ""))
} else {
sdk_in_parentheses(system_version).and_then(PyInt::parse)
};
if let Some(sdk) = sdk {
let (lo, hi) = versions.android_sdk_range;
if !sdk.within(lo, hi) {
issues.push(Issue {
kind: IssueKind::AndroidSdkRange,
severity,
message: format!(
"Android SDK {sdk} is outside the expected range [{lo}, {hi}]"
),
});
}
}
}
}
fn sdk_in_parentheses(text: &str) -> Option<&str> {
let text = text.strip_suffix('\n').unwrap_or(text);
let rest = skip_decimals(text)?;
let rest = match rest.strip_prefix('.') {
Some(fraction) => skip_decimals(fraction)?,
None => rest,
};
let group = rest
.trim_start_matches(is_python_space)
.strip_prefix('(')?
.strip_suffix(')')?;
(!group.is_empty() && group.chars().all(|c| decimal_value(c).is_some())).then_some(group)
}
fn skip_decimals(text: &str) -> Option<&str> {
let rest = text.trim_start_matches(|c| decimal_value(c).is_some());
(rest.len() < text.len()).then_some(rest)
}
const DECIMAL_ZEROS: [u32; 68] = [
0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66,
0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90,
0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10,
0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650,
0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16A60, 0x16AC0,
0x16B50, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E950,
0x1FBF0,
];
fn decimal_value(c: char) -> Option<u8> {
let code = u32::from(c);
let run = DECIMAL_ZEROS.partition_point(|&zero| zero <= code);
let zero = DECIMAL_ZEROS.get(run.checked_sub(1)?)?;
u8::try_from(code - zero).ok().filter(|digit| *digit < 10)
}
fn is_python_space(c: char) -> bool {
matches!(
c,
'\t'..='\r'
| '\u{1c}'..='\u{1f}'
| ' '
| '\u{85}'
| '\u{a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
)
}
fn is_int_space(c: char) -> bool {
if c.is_ascii() {
matches!(c, '\t'..='\r' | ' ')
} else {
is_python_space(c)
}
}
const MAX_STR_DIGITS: usize = 4300;
struct PyInt {
negative: bool,
digits: String,
}
impl PyInt {
fn parse(text: &str) -> Option<Self> {
let text = text.trim_matches(is_int_space);
let (negative, body) = match text.strip_prefix('-') {
Some(body) => (true, body),
None => (false, text.strip_prefix('+').unwrap_or(text)),
};
let mut digits = String::new();
let mut count = 0usize;
let mut after_underscore = true;
for c in body.chars() {
if c == '_' {
if after_underscore {
return None;
}
after_underscore = true;
continue;
}
let digit = decimal_value(c)?;
count += 1;
after_underscore = false;
if !(digits.is_empty() && digit == 0) {
digits.push(char::from(b'0' + digit));
}
}
if after_underscore || count > MAX_STR_DIGITS {
return None;
}
let negative = negative && !digits.is_empty();
if digits.is_empty() {
digits.push('0');
}
Some(Self { negative, digits })
}
fn within(&self, lo: u32, hi: u32) -> bool {
if self.negative || self.digits.len() > 10 {
return false;
}
self.digits
.parse::<u64>()
.is_ok_and(|value| u64::from(lo) <= value && value <= u64::from(hi))
}
}
impl fmt::Display for PyInt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.negative {
f.write_str("-")?;
}
f.write_str(&self.digits)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FingerprintConfig {
pub strict_mode: StrictMode,
pub auto_validate: bool,
pub preferred_transport: String,
pub warn_on_layer_mismatch: bool,
pub layer_override: Option<i32>,
pub randomize_msg_id_offset: bool,
pub msg_id_offset: u16,
}
impl FingerprintConfig {
#[must_use]
pub fn new(rng: &mut dyn FnMut(&mut [u8])) -> Self {
Self {
strict_mode: StrictMode::Warn,
auto_validate: true,
preferred_transport: "obfuscated".to_owned(),
warn_on_layer_mismatch: true,
layer_override: None,
randomize_msg_id_offset: true,
msg_id_offset: msg_id_offset(rng),
}
}
pub fn from_os_rng() -> Result<Self> {
let mut bytes = [0u8; 2];
crate::crypto::random_fill(&mut bytes)?;
Ok(Self::new(&mut |buf| buf.copy_from_slice(&bytes)))
}
#[must_use]
pub fn effective_layer(&self) -> i32 {
self.layer_override.unwrap_or(LAYER)
}
#[must_use]
pub fn effective_layer_for(&self, library_layer: i32) -> (i32, Option<String>) {
match self.layer_override {
Some(layer) => (layer, None),
None => (library_layer, layer_mismatch_warning(library_layer)),
}
}
#[must_use]
pub fn validate(&self, profile: &ApiProfile, versions: &Versions) -> Vec<Issue> {
self.validate_raw(&RawInitParams::from_profile(profile), versions)
}
#[must_use]
pub fn validate_raw(&self, params: &RawInitParams<'_>, versions: &Versions) -> Vec<Issue> {
if !self.auto_validate || self.strict_mode == StrictMode::Off {
return Vec::new();
}
validate_raw_init_params(params, versions, self.strict_mode)
}
}
#[must_use]
pub fn layer_mismatch_warning(library_layer: i32) -> Option<String> {
let diff = library_layer.abs_diff(LAYER);
(diff > crate::auth::LAYER_TOLERANCE).then(|| {
format!(
"Telethon TL layer ({library_layer}) differs from advertised layer \
({LAYER}, base={LAYER}) by {diff} layers. This may cause initConnection \
fingerprint inconsistencies. Consider updating Telethon or opentele2."
)
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct FingerprintConfigOptions {
pub strict_mode: Option<StrictMode>,
pub auto_validate: Option<bool>,
pub preferred_transport: Option<String>,
pub warn_on_layer_mismatch: Option<bool>,
pub layer_override: Option<i32>,
pub randomize_msg_id_offset: Option<bool>,
pub msg_id_offset: Option<u16>,
}
impl FingerprintConfig {
#[must_use]
pub fn with_options(options: FingerprintConfigOptions, rng: &mut dyn FnMut(&mut [u8])) -> Self {
let mut config = match options.msg_id_offset {
Some(offset) => {
let bytes = offset.to_le_bytes();
Self::new(&mut |buf| buf.copy_from_slice(&bytes))
}
None => Self::new(rng),
};
config.apply(options);
config
}
pub fn from_options(options: FingerprintConfigOptions) -> Result<Self> {
let mut config = match options.msg_id_offset {
Some(offset) => {
let bytes = offset.to_le_bytes();
Self::new(&mut |buf| buf.copy_from_slice(&bytes))
}
None => Self::from_os_rng()?,
};
config.apply(options);
Ok(config)
}
fn apply(&mut self, options: FingerprintConfigOptions) {
if let Some(mode) = options.strict_mode {
self.strict_mode = mode;
}
if let Some(auto) = options.auto_validate {
self.auto_validate = auto;
}
if let Some(transport) = options.preferred_transport {
self.preferred_transport = transport;
}
if let Some(warn) = options.warn_on_layer_mismatch {
self.warn_on_layer_mismatch = warn;
}
if options.layer_override.is_some() {
self.layer_override = options.layer_override;
}
if let Some(randomize) = options.randomize_msg_id_offset {
self.randomize_msg_id_offset = randomize;
}
}
}
#[must_use]
pub fn to_offset(value: i64) -> Option<u16> {
u16::try_from(value).ok()
}
pub fn msg_id_offset_for_os() -> Result<u16> {
let mut bytes = [0u8; 2];
crate::crypto::random_fill(&mut bytes)?;
Ok(msg_id_offset(&mut |buf| buf.copy_from_slice(&bytes)))
}
#[must_use]
pub fn msg_id_offset(rng: &mut dyn FnMut(&mut [u8])) -> u16 {
let mut bytes = [0u8; 2];
rng(&mut bytes);
u16::from_le_bytes(bytes)
}
#[must_use]
pub fn is_valid_msg_id(msg_id: i64, from_client: bool, now_unix: i64) -> bool {
if msg_id <= 0 {
return false;
}
if (msg_id % 2 == 1) != from_client {
return false;
}
(msg_id >> 32).abs_diff(now_unix) < 300
}