ZaStoGram_desktop/Telegram/SourceFiles/tests/test_mtproxy_sni_hash_privacy.py
loop-uh b4d1418e81 Salt the logged SNI hash, because unsalted it named the domain
The masqueraded domain is logged as a hash so a log can be shared
without naming it. That did not hold: the name is short and drawn
from a small set of plausible ones, so eight bytes of SHA-256 fall
to a wordlist immediately - www.google.com came back out of a real
log this way in under a second, from 12900 candidates, while
working out which relay a user was on.

The salt is sixteen random bytes drawn once per run and hashed
before the domain, so no prefix of the digest depends on the domain
alone. What the field is actually for still works: inside one log,
connections carrying the same name still share a hash. Across two
logs they no longer line up, which is the right trade for a field
whose whole purpose was to not name the domain.

Both report builders now go through one helper; neither hashes the
domain directly. Guarded, including that the salt is drawn once
rather than per call - per call would leave the field useless for
matching within a log.
2026-07-26 19:15:13 +03:00

53 lines
2.3 KiB
Python

"""The logged SNI hash has to be salted, or it is not a hash worth logging.
The masqueraded domain goes into the log as a hash so a log can be handed
over without naming it. Unsalted, that does not hold: the name is short and
comes from a small set of plausible ones, so eight bytes of SHA-256 fall to a
wordlist immediately - `www.google.com` was recovered from a real log this way
in well under a second while investigating a proxy.
Salting per run keeps the field's actual use - telling apart, inside one log,
which connections carried which name - and removes the part that never worked.
The cost is that hashes from two runs no longer line up, which is the correct
trade for a field whose stated purpose was not naming the domain.
"""
from pathlib import Path
SOURCE_DIR = Path(__file__).resolve().parents[1]
DIAGNOSTICS_CPP = (SOURCE_DIR / "mtproto" / "proxy" / "mtproxy"
/ "tls_socket_diagnostics.cpp")
def test_the_salt_is_random_and_drawn_once_per_run():
source = DIAGNOSTICS_CPP.read_text(encoding="utf-8")
body = source.split("QByteArray SniHashSalt()", 1)[1].split("\n}", 1)[0]
# Random, not derived from anything an outsider could reconstruct.
assert "base::RandomFill" in body
# Once per run: a fresh salt per call would make the field useless for
# matching connections inside one log, which is the only thing it is for.
assert "static const auto" in body
def test_every_logged_sni_hash_goes_through_the_salt():
source = DIAGNOSTICS_CPP.read_text(encoding="utf-8")
assert "QByteArray HashedSni(" in source
# Both report builders hash the domain; neither may do it directly.
assert source.count("HashedSni(domain)") == 2
hashed_raw = "QCryptographicHash::hash(\n\t\tdomainBytes"
assert hashed_raw not in source
# And the salt has to be the first thing hashed, not appended after the
# domain, so that no prefix of the digest depends on the domain alone.
body = source.split("QByteArray HashedSni(", 1)[1].split("\n}", 1)[0]
salt_at = body.index("SniHashSalt()")
domain_at = body.index("domain.data()")
assert salt_at < domain_at
def test_the_reason_is_recorded_next_to_the_code():
source = DIAGNOSTICS_CPP.read_text(encoding="utf-8")
# Whoever removes the salt for convenience should meet the reason first.
assert "www.google.com was read back out of a log" in source