"""TLS fingerprint profiles and registry for CycleTLS.
This module provides a registry architecture for reusable TLS fingerprint
configurations backed by JSON files.
"""
from __future__ import annotations
import json
import os
import random as _random
import re
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar, Optional
[docs]
class BrowserFamily(str, Enum):
"""Browser family for fingerprint lookup."""
BRAVE = "brave"
CHROME = "chrome"
CHROMIUM = "chromium"
EDGE = "edge"
FIREFOX = "firefox"
OPERA = "opera"
SAFARI = "safari"
SAMSUNG = "samsung"
_FAMILY_PREFIXES: dict[BrowserFamily, tuple[str, ...]] = {
BrowserFamily.BRAVE: ("brave",),
BrowserFamily.CHROME: ("chrome",),
BrowserFamily.CHROMIUM: ("chromium",),
BrowserFamily.EDGE: ("edge", "msedge"),
BrowserFamily.FIREFOX: ("firefox",),
BrowserFamily.OPERA: ("opera",),
BrowserFamily.SAFARI: ("safari",),
BrowserFamily.SAMSUNG: ("samsung",),
}
def _version_tuple(name: str) -> tuple[int, ...]:
"""Extract numeric version components from a profile name for sorting."""
parts = re.findall(r"\d+", name)
return tuple(int(p) for p in parts) if parts else (0,)
[docs]
@dataclass
class TLSFingerprint:
"""A reusable TLS fingerprint configuration."""
name: str
ja3: str
ja4r: Optional[str] = None
http2_fingerprint: Optional[str] = None
quic_fingerprint: Optional[str] = None
user_agent: Optional[str] = None
header_order: Optional[list[str]] = None
headers: Optional[dict[str, str]] = None
disable_grease: bool = False
force_http1: bool = False
force_http3: bool = False
@classmethod
def from_dict(cls, data: dict[str, Any]) -> TLSFingerprint:
return cls(
name=data["name"],
ja3=data["ja3"],
ja4r=data.get("ja4r"),
http2_fingerprint=data.get("http2_fingerprint"),
quic_fingerprint=data.get("quic_fingerprint"),
user_agent=data.get("user_agent"),
header_order=data.get("header_order"),
headers=data.get("headers"),
disable_grease=data.get("disable_grease", False),
force_http1=data.get("force_http1", False),
force_http3=data.get("force_http3", False),
)
@classmethod
def from_json(cls, path: str | Path) -> TLSFingerprint:
with open(path, "r") as f:
data = json.load(f)
return cls.from_dict(data)
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"name": self.name,
"ja3": self.ja3,
}
if self.ja4r is not None:
result["ja4r"] = self.ja4r
if self.http2_fingerprint is not None:
result["http2_fingerprint"] = self.http2_fingerprint
if self.quic_fingerprint is not None:
result["quic_fingerprint"] = self.quic_fingerprint
if self.user_agent is not None:
result["user_agent"] = self.user_agent
if self.header_order is not None:
result["header_order"] = self.header_order
if self.headers is not None:
result["headers"] = self.headers
if self.disable_grease:
result["disable_grease"] = True
if self.force_http1:
result["force_http1"] = True
if self.force_http3:
result["force_http3"] = True
return result
def to_json(self, path: str | Path) -> None:
with open(path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
def _dynamic_sec_ch_ua(self) -> dict[str, str] | None:
"""Compute sec-ch-ua headers from user_agent for Chromium-family browsers."""
if not self.user_agent or not self.header_order:
return None
ua = self.user_agent
# Extract major version
match = re.search(r"(?:Chrome|Chromium|HeadlessChrome)/(\d+)", ua)
if not match:
return None
major = match.group(1)
# Determine platform
if "Linux" in ua:
platform = "Linux"
elif "Macintosh" in ua or "Mac OS X" in ua:
platform = "macOS"
elif "Windows" in ua:
platform = "Windows"
elif "Android" in ua:
platform = "Android"
else:
platform = ""
# Determine mobile
mobile = "?1" if "Android" in ua or "Mobile" in ua else "?0"
# Determine browser brand from profile name and UA
name = self.name.lower()
if name.startswith("brave"):
brand = "Brave"
elif name.startswith("opera") or "opr/" in ua.lower():
brand = "Opera"
elif name.startswith("edge") or name.startswith("msedge"):
brand = "Microsoft Edge"
elif name.startswith("chrome"):
brand = "Google Chrome"
elif name.startswith("chromium"):
brand = None
else:
# Fallback: Chrome if "Chrome/" in UA but not Chromium
brand = "Google Chrome" if "Chrome/" in ua and "Chromium/" not in ua else None
# Determine brand version (Opera uses its own version, others match Chromium)
if brand == "Opera":
opr_match = re.search(r"OPR/(\d+)", ua, re.IGNORECASE)
brand_version = opr_match.group(1) if opr_match else major
else:
brand_version = major
# Build brand string (Chrome 120+ format)
if brand:
sec_ch_ua = (
f'"Chromium";v="{major}", "{brand}";v="{brand_version}", "Not/A)Brand";v="99"'
)
else:
sec_ch_ua = f'"Chromium";v="{major}", "Not/A)Brand";v="99"'
result: dict[str, str] = {}
if "sec-ch-ua" in self.header_order:
result["sec-ch-ua"] = sec_ch_ua
if "sec-ch-ua-mobile" in self.header_order:
result["sec-ch-ua-mobile"] = mobile
if "sec-ch-ua-platform" in self.header_order:
result["sec-ch-ua-platform"] = f'"{platform}"' if platform else ""
return result if result else None
def apply_to_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if "ja3" not in kwargs:
kwargs["ja3"] = self.ja3
if self.ja4r is not None and "ja4r" not in kwargs:
kwargs["ja4r"] = self.ja4r
if self.http2_fingerprint is not None and "http2_fingerprint" not in kwargs:
kwargs["http2_fingerprint"] = self.http2_fingerprint
if self.quic_fingerprint is not None and "quic_fingerprint" not in kwargs:
kwargs["quic_fingerprint"] = self.quic_fingerprint
if self.user_agent is not None and "user_agent" not in kwargs:
kwargs["user_agent"] = self.user_agent
if self.header_order is not None and "header_order" not in kwargs:
kwargs["header_order"] = self.header_order
if self.headers is not None:
request_headers = kwargs.get("headers") or {}
merged = {**self.headers, **request_headers}
kwargs["headers"] = merged
# Inject dynamic sec-ch-ua headers (computed from user_agent)
dynamic_ua = self._dynamic_sec_ch_ua()
if dynamic_ua:
request_headers = kwargs.get("headers") or {}
for key, value in dynamic_ua.items():
if key not in request_headers:
request_headers.setdefault(key, value)
kwargs["headers"] = request_headers
if self.disable_grease and "disable_grease" not in kwargs:
kwargs["disable_grease"] = True
if self.force_http1 and "force_http1" not in kwargs:
kwargs["force_http1"] = True
if self.force_http3 and "force_http3" not in kwargs:
kwargs["force_http3"] = True
return kwargs
DEFAULT_FINGERPRINTS_FILE = Path(__file__).resolve().parent / "data" / "fingerprints.json"
[docs]
class FingerprintRegistry:
"""Registry for managing fingerprint profiles."""
_profiles: ClassVar[dict[str, TLSFingerprint]] = {}
@classmethod
def register(cls, profile: TLSFingerprint) -> None:
cls._profiles[profile.name] = profile
@classmethod
def register_many(cls, profiles: list[TLSFingerprint]) -> None:
for profile in profiles:
cls.register(profile)
@classmethod
def unregister(cls, name: str) -> bool:
if name in cls._profiles:
del cls._profiles[name]
return True
return False
@classmethod
def get(cls, name: str) -> TLSFingerprint:
if name not in cls._profiles:
raise KeyError(f"Fingerprint profile '{name}' not found. Available: {cls.list()}")
return cls._profiles[name]
@classmethod
def get_or_none(cls, name: str) -> Optional[TLSFingerprint]:
return cls._profiles.get(name)
@classmethod
def list(cls) -> list[str]:
return list(cls._profiles.keys())
@classmethod
def all(cls) -> dict[str, TLSFingerprint]:
return dict(cls._profiles)
@classmethod
def clear(cls) -> None:
cls._profiles.clear()
@classmethod
def _candidates(
cls,
family: Optional[BrowserFamily] = None,
platform: Optional[Platform] = None,
) -> list[TLSFingerprint]:
if family is not None:
return cls.by_family(family, platform=platform)
if platform is not None:
suffix = f"_{platform.value}"
return [fp for fp in cls._profiles.values() if fp.name.endswith(suffix)]
return list(cls._profiles.values())
[docs]
@classmethod
def by_family(
cls, family: BrowserFamily, platform: Optional[Platform] = None
) -> list[TLSFingerprint]:
"""Return all profiles whose name starts with any prefix for *family*.
If *platform* is given, only profiles whose name ends with
``_<platform.value>`` are returned. Profiles without a platform suffix
are excluded when *platform* is specified.
"""
prefixes = _FAMILY_PREFIXES.get(family, ())
candidates = [
fp for fp in cls._profiles.values() if any(fp.name.startswith(p) for p in prefixes)
]
if platform is not None:
suffix = f"_{platform.value}"
candidates = [fp for fp in candidates if fp.name.endswith(suffix)]
return candidates
[docs]
@classmethod
def latest(
cls,
family: Optional[BrowserFamily] = None,
platform: Optional[Platform] = None,
) -> TLSFingerprint:
"""Return the profile with the highest version number.
*family* and *platform* are optional filters; omit both to search the
entire registry.
"""
candidates = cls._candidates(family, platform)
if not candidates:
raise KeyError(
f"No profiles found"
f"{f' for family {family!r}' if family else ''}"
f"{f' on platform {platform!r}' if platform else ''}. "
f"Available: {cls.list()}"
)
return max(candidates, key=lambda fp: _version_tuple(fp.name))
[docs]
@classmethod
def random(
cls,
family: Optional[BrowserFamily] = None,
platform: Optional[Platform] = None,
) -> TLSFingerprint:
"""Return a random profile.
*family* and *platform* are optional filters; omit both to pick from
the entire registry.
"""
candidates = cls._candidates(family, platform)
if not candidates:
raise KeyError(
f"No profiles found"
f"{f' for family {family!r}' if family else ''}"
f"{f' on platform {platform!r}' if platform else ''}. "
f"Available: {cls.list()}"
)
return _random.choice(candidates)
@classmethod
def load_from_file(cls, path: str | Path, clear: bool = False) -> list[TLSFingerprint]:
payload = json.loads(Path(path).read_text())
raw_items: list[dict[str, Any]]
if isinstance(payload, dict) and isinstance(payload.get("fingerprints"), list):
raw_items = [v for v in payload["fingerprints"] if isinstance(v, dict)]
elif isinstance(payload, list):
raw_items = [v for v in payload if isinstance(v, dict)]
else:
raise ValueError(f"Unsupported fingerprint registry format in {path}")
loaded = [
TLSFingerprint.from_dict(item) for item in raw_items if "name" in item and "ja3" in item
]
if clear:
cls.clear()
cls.register_many(loaded)
return loaded
@classmethod
def save_to_file(cls, path: str | Path) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema": "cycletls_fingerprint_registry/v1",
"fingerprints": [fp.to_dict() for _, fp in sorted(cls._profiles.items())],
}
target.write_text(json.dumps(payload, indent=2) + "\n")
def _extract_header_order_from_trackme_raw(raw: dict[str, Any]) -> list[str]:
http2 = raw.get("http2", {})
sent_frames = http2.get("sent_frames", [])
if not isinstance(sent_frames, list):
return []
for frame in sent_frames:
if not isinstance(frame, dict) or frame.get("frame_type") != "HEADERS":
continue
headers = frame.get("headers")
if not isinstance(headers, list):
continue
ordered: list[str] = []
for header in headers:
if not isinstance(header, str) or header.startswith(":") or ":" not in header:
continue
name = header.split(":", 1)[0].strip().lower()
if name:
ordered.append(name)
if ordered:
return ordered
return []
def _extract_trackme_version(browser: str, user_agent: str) -> str:
token_map = {
"firefox": r"Firefox/([0-9.]+)",
"chromium": r"(?:HeadlessChrome|Chrome)/([0-9.]+)",
"chrome": r"(?:HeadlessChrome|Chrome)/([0-9.]+)",
}
pattern = token_map.get(browser.lower())
if not pattern:
return "unknown"
match = re.search(pattern, user_agent)
return match.group(1) if match else "unknown"
def _build_trackme_name(browser: str, version: str) -> str:
safe_version = re.sub(r"[^0-9A-Za-z]+", "_", version).strip("_") or "unknown"
return f"{browser}_{safe_version}".lower()
def _normalize_trackme_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
fingerprints = payload.get("fingerprints")
if isinstance(fingerprints, list):
normalized: list[dict[str, Any]] = []
for entry in fingerprints:
if not isinstance(entry, dict):
continue
normalized.append(
{
"name": entry.get("name"),
"ja3": entry.get("ja3"),
"ja4r": entry.get("ja4_r") or entry.get("ja4r"),
"http2_fingerprint": entry.get("http2") or entry.get("http2_fingerprint"),
"user_agent": entry.get("ua") or entry.get("user_agent"),
"header_order": entry.get("header_order"),
"headers": entry.get("headers"),
}
)
return normalized
normalized_old: list[dict[str, Any]] = []
for browser, entry in payload.items():
if not isinstance(entry, dict) or "error" in entry:
continue
raw_candidate = entry.get("raw")
raw: dict[str, Any] = (
{str(k): v for k, v in raw_candidate.items()} if isinstance(raw_candidate, dict) else {}
)
user_agent = entry.get("ua") or raw.get("user_agent") or ""
version = _extract_trackme_version(str(browser), str(user_agent))
normalized_old.append(
{
"name": _build_trackme_name(str(browser), version),
"ja3": entry.get("ja3"),
"ja4r": entry.get("ja4_r") or entry.get("ja4r"),
"http2_fingerprint": entry.get("http2") or entry.get("http2_akamai"),
"user_agent": user_agent,
"header_order": _extract_header_order_from_trackme_raw(raw),
}
)
return normalized_old
[docs]
def load_trackme_fingerprints(
path: str | Path,
persist_path: str | Path | None = None,
) -> list[TLSFingerprint]:
payload = json.loads(Path(path).read_text())
entries = _normalize_trackme_payload(payload)
loaded: list[TLSFingerprint] = []
for entry in entries:
if not entry.get("name") or not entry.get("ja3"):
continue
profile = TLSFingerprint.from_dict(entry)
FingerprintRegistry.register(profile)
loaded.append(profile)
if persist_path is not None:
FingerprintRegistry.save_to_file(persist_path)
return loaded
_PROFILE_EXPORTS: dict[str, str] = {
"BRAVE_1_63": "brave_1_63_win",
"CHROME_120": "chrome_120_win",
"CHROME_121": "chrome_121_win",
"CHROME_122": "chrome_122_win",
"CHROME_123": "chrome_123_win",
"CHROME_124": "chrome_124_win",
"CHROME_125": "chrome_125_win",
"CHROME_146": "chrome_146_0_0_0_linux",
"CHROME_146_MAC": "chrome_146_0_0_0_mac",
"CHROME_146_WIN": "chrome_146_0_0_0_win",
"CHROME_147": "chrome_147_0_0_0_linux",
"CHROME_147_MAC": "chrome_147_0_0_0_mac",
"CHROME_147_WIN": "chrome_147_0_0_0_win",
"CHROME_148": "chrome_148_0_0_0_linux",
"CHROME_148_MAC": "chrome_148_0_0_0_mac",
"CHROME_148_WIN": "chrome_148_0_0_0_win",
"CHROME_149": "chrome_149_0_0_0_linux",
"CHROME_ANDROID": "chrome_android",
"CHROME_ANDROID_113": "chrome_android_113_0_0_0_android",
"CHROME_LINUX": "chrome_linux",
"CHROMIUM_134": "chromium_134_0_6998_35",
"CHROMIUM_145_0_7632_6_LINUX": "chromium_145_0_7632_6_linux",
"CHROMIUM_145_0_7632_6_MAC": "chromium_145_0_7632_6_mac",
"CHROMIUM_145_0_7632_6_WIN": "chromium_145_0_7632_6_win",
"CHROMIUM_145_MAC": "chromium_145_0_0_0_mac",
"CHROMIUM_145_WIN": "chromium_145_0_0_0_win",
"CHROMIUM_147_0_7727_15_LINUX": "chromium_147_0_7727_15_linux",
"CHROMIUM_147_MAC": "chromium_147_0_0_0_mac",
"CHROMIUM_147_WIN": "chromium_147_0_0_0_win",
"CHROMIUM_148_0_7778_96_LINUX": "chromium_148_0_7778_96_linux",
"CHROMIUM_148_MAC": "chromium_148_0_0_0_mac",
"CHROMIUM_148_WIN": "chromium_148_0_0_0_win",
"EDGE_120": "edge_120_win",
"EDGE_121": "edge_121_win",
"EDGE_122": "edge_122_win",
"FIREFOX_121": "firefox_121_win",
"FIREFOX_122": "firefox_122_win",
"FIREFOX_123": "firefox_123_win",
"FIREFOX_124": "firefox_124_win",
"FIREFOX_135_LINUX": "firefox_135_0_linux",
"FIREFOX_146_LINUX": "firefox_146_0_linux",
"FIREFOX_146_MAC": "firefox_146_0_mac",
"FIREFOX_146_WIN": "firefox_146_0_win",
"FIREFOX_148_LINUX": "firefox_148_0_linux",
"FIREFOX_148_MAC": "firefox_148_0_mac",
"FIREFOX_148_WIN": "firefox_148_0_win",
"FIREFOX_150_LINUX": "firefox_150_0_linux",
"FIREFOX_150_MAC": "firefox_150_0_mac",
"FIREFOX_150_WIN": "firefox_150_0_win",
"FIREFOX_LINUX": "firefox_linux",
"EDGE_146_LINUX": "msedge_146_0_0_0_linux",
"EDGE_146_MAC": "msedge_146_0_0_0_mac",
"EDGE_146_WIN": "msedge_146_0_0_0_win",
"EDGE_147_LINUX": "msedge_147_0_0_0_linux",
"EDGE_147_MAC": "msedge_147_0_0_0_mac",
"EDGE_147_WIN": "msedge_147_0_0_0_win",
"EDGE_148_LINUX": "msedge_148_0_0_0_linux",
"EDGE_148_MAC": "msedge_148_0_0_0_mac",
"EDGE_148_WIN": "msedge_148_0_0_0_win",
"OPERA_106": "opera_106_win",
"SAFARI_17": "safari_17_mac",
"SAFARI_26": "safari_26_0_mac",
"SAFARI_26_4_LINUX": "safari_26_4_linux",
"SAFARI_26_4_MAC": "safari_26_4_mac",
"SAFARI_26_4_WIN": "safari_26_4_win",
"SAFARI_26_LINUX": "safari_26_0_linux",
"SAFARI_26_WIN": "safari_26_0_win",
"SAFARI_IOS": "safari_ios",
"SAMSUNG_BROWSER_23": "samsung_browser_23_android",
}
# Initialize registry from the default JSON-backed profile list.
if DEFAULT_FINGERPRINTS_FILE.exists():
FingerprintRegistry.load_from_file(DEFAULT_FINGERPRINTS_FILE, clear=True)
else:
print(
f"cycletls: warning: default fingerprints file not found: {DEFAULT_FINGERPRINTS_FILE}. "
"Built-in profiles will not be available.",
file=sys.stderr,
)
# Optional extra runtime file(s)
_extra_file = os.environ.get("CYCLETLS_FINGERPRINTS_FILE")
if _extra_file:
try:
FingerprintRegistry.load_from_file(_extra_file, clear=False)
except Exception as _exc:
print(
f"cycletls: warning: failed to load CYCLETLS_FINGERPRINTS_FILE={_extra_file!r}: {_exc}",
file=sys.stderr,
)
_trackme_capture_path = os.environ.get("CYCLETLS_TRACKME_FINGERPRINT_FILE")
if _trackme_capture_path:
try:
load_trackme_fingerprints(_trackme_capture_path, persist_path=DEFAULT_FINGERPRINTS_FILE)
except Exception as _exc:
print(
f"cycletls: warning: failed to load CYCLETLS_TRACKME_FINGERPRINT_FILE={_trackme_capture_path!r}: {_exc}",
file=sys.stderr,
)
for export_name, profile_name in _PROFILE_EXPORTS.items():
profile = FingerprintRegistry.get_or_none(profile_name)
if profile is not None:
globals()[export_name] = profile
else:
print(
f"cycletls: warning: built-in profile '{profile_name}' not found in registry.",
file=sys.stderr,
)
_PROFILE_EXPORT_NAMES = list(_PROFILE_EXPORTS.keys())
__all__ = [
"BrowserFamily",
"Platform",
"TLSFingerprint",
"FingerprintRegistry",
"DEFAULT_FINGERPRINTS_FILE",
"load_trackme_fingerprints",
] + _PROFILE_EXPORT_NAMES