ZaStoGram_desktop/Telegram/SourceFiles/mtproto/proxy/mtproxy/tls_socket_diagnostics.cpp
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

414 lines
15 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "mtproto/proxy/mtproxy/tls_socket.h"
#include "base/random.h"
#include "mtproto/proxy/mtproxy/handshake_diagnosis.h"
#include "mtproto/proxy/mtproxy/tls_socket_utils.h"
#include "mtproto/proxy/diagnostics.h"
#include "mtproto/proxy/proxy_endpoint_context.h"
#include "mtproto/proxy/proxy_services.h"
#include "mtproto/runtime/runtime_environment.h"
#include <QtCore/QCryptographicHash>
#include <algorithm>
namespace MTP::details {
namespace {
// The masqueraded domain is logged as a hash so that a log can be shared
// without naming it. An unsalted hash does not achieve that: the name is
// short and comes from a small set of plausible ones, so eight bytes of
// SHA-256 fall to a wordlist in well under a second - which is exactly how
// www.google.com was read back out of a log while investigating.
//
// Salting with a value drawn once per run keeps what the field is for -
// telling apart, inside one log, which connections carried which name - and
// takes away what it never should have offered, which is recovering the name
// from the log alone. Hashes from two runs no longer line up either, and that
// is the price: correlating across logs now needs the domains themselves.
[[nodiscard]] QByteArray SniHashSalt() {
static const auto result = [] {
auto salt = QByteArray(16, Qt::Uninitialized);
base::RandomFill(salt.data(), salt.size());
return salt;
}();
return result;
}
[[nodiscard]] QByteArray HashedSni(bytes::const_span domain) {
auto hash = QCryptographicHash(QCryptographicHash::Sha256);
hash.addData(SniHashSalt());
hash.addData(QByteArray(
reinterpret_cast<const char*>(domain.data()),
int(domain.size())));
return hash.result();
}
} // namespace
ProxyTransportFailure TlsSocket::proxyTransportFailure() const {
return _terminal ? _terminalFailure : collectTransportFailure();
}
ProxyTransportFailure TlsSocket::collectTransportFailure() const {
const auto clientHelloKnown = _clientHelloBytes > 0;
const auto domain = domainFromSecret();
const auto domainHash = HashedSni(domain);
const auto parserStage = [&] {
switch (_phase) {
case HandshakePhase::None: return u"tcp_connect"_q;
case HandshakePhase::TcpConnected: return u"client_hello_write"_q;
case HandshakePhase::ClientHelloSent: return u"server_hello"_q;
case HandshakePhase::ServerHelloOk: return u"tls_appdata"_q;
case HandshakePhase::FirstDataReceived: return u"mtproto"_q;
}
return QString();
}();
return {
.reason = MtProxy::ToProxyMtproxyTerminalReason(failureReason()),
.error = _connectionError,
.closeOrigin = _closeOrigin,
.parserStage = parserStage,
.rxAfterClientHello = clientHelloKnown
? std::make_optional(_rxAfterClientHello)
: std::nullopt,
.rxClass = clientHelloKnown ? responseClass() : QString(),
.block = blockToken(),
.tlsRecordType = responseRecordType(),
.tlsRecordVersion = responseRecordVersion(),
.tlsRecordLength = responseRecordLength(),
.responsePrefixHash = responsePrefixHash(),
.sniLength = clientHelloKnown
? std::make_optional(int(domain.size()))
: std::nullopt,
.sniHash = clientHelloKnown
? QString::fromLatin1(domainHash.toHex().left(16))
: QString(),
.clientHelloBytes = clientHelloKnown
? std::make_optional(_clientHelloBytes)
: std::nullopt,
.clientHelloWrites = clientHelloKnown
? std::make_optional(_clientHelloWrites)
: std::nullopt,
.clientHelloAcceptedBytes = clientHelloKnown
? std::make_optional(_clientHelloAcceptedBytes)
: std::nullopt,
.sentTlsProfile = clientHelloKnown
? std::make_optional(_sentTlsProfile)
: std::nullopt,
.pskOffered = clientHelloKnown
? std::make_optional(_syntheticPskOffered)
: std::nullopt,
.fragmentedClientHello = clientHelloKnown
? std::make_optional(_clientHelloFragmented)
: std::nullopt,
.clientHelloFragmentSplit = _clientHelloFragmented
? std::make_optional(_clientHelloFragmentSplit)
: std::nullopt,
.clientHelloFragmentDelayMs = _clientHelloFragmented
? std::make_optional(_clientHelloFragmentDelayMs)
: std::nullopt,
.dnsMs = std::nullopt,
.tcpMs = (_tcpConnectedAt && _mtproxyAttemptStartedAt)
? std::make_optional(_tcpConnectedAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.firstRxMs = (_firstRxAt && _mtproxyAttemptStartedAt)
? std::make_optional(_firstRxAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.serverHelloMs = (_serverHelloAt && _mtproxyAttemptStartedAt)
? std::make_optional(_serverHelloAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.appDataMs = (_firstAppDataAt && _mtproxyAttemptStartedAt)
? std::make_optional(_firstAppDataAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.livenessReported = _mtproxyAttempt.traceId
&& !_runtime->proxyEndpointContext().traceActive(
_mtproxyAttempt.traceId),
.attribution = failureAttribution(),
};
}
QString TlsSocket::responseClass() const {
return FakeTlsResponseClass(_responsePrefix, _rxAfterClientHello);
}
QString TlsSocket::clockReferenceName() const {
// Which corrections stood behind the time we sent. "none" is the one that
// matters: it means the raw system clock went out, and the skew reported
// next to it is zero by construction rather than by being right.
if (!_clientHelloTimestamp) {
return QString();
} else if (_clockFromMtproto && _clockFromHttp) {
return u"both"_q;
} else if (_clockFromMtproto) {
return u"mtproto"_q;
} else if (_clockFromHttp) {
return u"http"_q;
}
return u"none"_q;
}
QString TlsSocket::blockToken() const {
// Only meaningful for the ambiguous "ClientHello sent, no ServerHello"
// stall - other phases have unambiguous reasons of their own. The peer
// actively ending the connection (FIN or a network-level reset) is what
// separates an on-path reset from our own local timeout on silence.
const auto evidence = MtProxy::HandshakeBlockEvidence{
.isNoServerHelloStall = (_phase == HandshakePhase::ClientHelloSent)
&& (failureReason()
== MtProxy::FailureReason::ClientHelloSentNoServerHello),
.clientHelloBytes = _clientHelloBytes,
.clientHelloAcceptedBytes = _clientHelloAcceptedBytes,
.rxAfterClientHello = _rxAfterClientHello,
.responsePrefix = _responsePrefix,
.closeOrigin = _closeOrigin,
.error = _connectionError,
};
return MtProxy::HandshakeBlockToken(
MtProxy::AnalyzeHandshakeBlock(evidence),
evidence);
}
ProxyFailureAttribution TlsSocket::failureAttribution() const {
const auto reason = failureReason();
if (_clientHelloBytes > 0
&& _clientHelloAcceptedBytes < _clientHelloBytes) {
return ProxyFailureAttribution::Local;
}
if (reason == MtProxy::FailureReason::ClientHelloSentNoServerHello) {
const auto evidence = MtProxy::HandshakeBlockEvidence{
.isNoServerHelloStall = true,
.clientHelloBytes = _clientHelloBytes,
.clientHelloAcceptedBytes = _clientHelloAcceptedBytes,
.rxAfterClientHello = _rxAfterClientHello,
.responsePrefix = _responsePrefix,
.closeOrigin = _closeOrigin,
.error = _connectionError,
};
return MtProxy::AnalyzeHandshakeBlock(evidence).attribution;
}
if (reason == MtProxy::FailureReason::TlsAlertAfterClientHello) {
return ProxyFailureAttribution::Client;
} else if (reason == MtProxy::FailureReason::ServerHelloHmacMismatch
|| reason == MtProxy::FailureReason::ServerHelloForeignTls
|| reason == MtProxy::FailureReason::ProxyProtocolBadResponse) {
return ProxyFailureAttribution::Peer;
} else if (_closeOrigin == ProxyCloseOrigin::PeerClosed) {
return ProxyFailureAttribution::Peer;
} else if (_closeOrigin == ProxyCloseOrigin::NetworkError
&& (_connectionError == ProxyConnectionError::Network
|| _connectionError == ProxyConnectionError::ConnectionRefused
|| _connectionError == ProxyConnectionError::HostNotFound)) {
return ProxyFailureAttribution::Network;
} else if (_closeOrigin == ProxyCloseOrigin::LocalTimeout) {
return ProxyFailureAttribution::Unclear;
} else if (_closeOrigin == ProxyCloseOrigin::ProtocolRejected) {
return ProxyFailureAttribution::Client;
}
return (reason == MtProxy::FailureReason::None)
? ProxyFailureAttribution::None
: ProxyFailureAttribution::Unclear;
}
QString TlsSocket::responseRecordType() const {
if (_responsePrefix.isEmpty()) {
return QString();
}
switch (uchar(_responsePrefix[0])) {
case 0x14: return u"change_cipher_spec"_q;
case 0x15: return u"alert"_q;
case 0x16: return u"handshake"_q;
case 0x17: return u"application_data"_q;
}
return u"unknown"_q;
}
QString TlsSocket::responseRecordVersion() const {
if (_responsePrefix.size() < 3) {
return QString();
}
return u"0x%1%2"_q
.arg(uchar(_responsePrefix[1]), 2, 16, QChar('0'))
.arg(uchar(_responsePrefix[2]), 2, 16, QChar('0'));
}
std::optional<int> TlsSocket::responseRecordLength() const {
if (_responsePrefix.size() < 5) {
return std::nullopt;
}
return (int(uchar(_responsePrefix[3])) << 8)
| int(uchar(_responsePrefix[4]));
}
QString TlsSocket::responsePrefixHash() const {
if (_responsePrefix.isEmpty()) {
return QString();
}
const auto hash = QCryptographicHash::hash(
_responsePrefix,
QCryptographicHash::Sha256);
return QString::fromLatin1(hash.toHex().left(16));
}
void TlsSocket::noteIncoming(const QByteArray &data) {
if (data.isEmpty()) {
return;
}
if (!_firstRxAt) {
_firstRxAt = crl::now();
}
_rxAfterClientHello += data.size();
const auto left = 16 - _responsePrefix.size();
if (left > 0) {
_responsePrefix.append(data.constData(), std::min(left, data.size()));
}
}
void TlsSocket::reportTransportEvent(
ProxyDiagnosticsPhase phase,
ProxyDiagnosticsSeverity severity,
const QString &message) {
const auto now = crl::now();
const auto domain = domainFromSecret();
const auto domainHash = HashedSni(domain);
const auto clientHelloKnown = _clientHelloBytes > 0;
auto attempt = _mtproxyAttempt;
if (attempt.connectionId.isEmpty()) {
attempt.connectionId = _debugId;
}
ReportProxyEvent(_runtime, {
.phase = phase,
.error = (severity == ProxyDiagnosticsSeverity::Error)
? MtProxy::ToProxyConnectionError(failureReason())
: ProxyConnectionError::None,
.mtproxyReason = (severity == ProxyDiagnosticsSeverity::Error)
? MtProxy::ToProxyMtproxyTerminalReason(failureReason())
: ProxyMtproxyTerminalReason::None,
.attempt = attempt,
.severity = severity,
.proxy = _proxy,
.transport = ProxyDiagnosticsTransportName(
_proxy,
_stealth.transport),
.connectionId = _debugId,
.message = message,
.canonical = ProxyDiagnosticsEndpointText(
_endpointId.canonical.originalHost,
_endpointId.canonical.port),
.route = ProxyDiagnosticsEndpointText(
_endpointId.route.address,
_endpointId.route.port),
.proxyKeyHash = ProxyDiagnosticsKeyHash(
MtProxy::EndpointKey(_endpointId.canonical)),
.profile = clientHelloKnown
? ProxyDiagnosticsTlsProfileName(_sentTlsProfile)
: QString(),
.configuredProfile = ProxyDiagnosticsTlsProfileName(
_configuredTlsProfile),
.effectiveProfile = ProxyDiagnosticsTlsProfileName(_tlsProfile),
.recipeLevel = _mtproxyPlan.admitted
? std::make_optional(_mtproxyPlan.recipeLevel)
: std::nullopt,
.pskOffered = clientHelloKnown
? std::make_optional(_syntheticPskOffered)
: std::nullopt,
.fragmentedClientHello = clientHelloKnown
? std::make_optional(_clientHelloFragmented)
: std::nullopt,
.phaseAtFailure = (severity == ProxyDiagnosticsSeverity::Error)
? proxyTransportFailure().parserStage
: QString(),
.clientHelloBytes = clientHelloKnown
? std::make_optional(_clientHelloBytes)
: std::nullopt,
.clientHelloWrites = clientHelloKnown
? std::make_optional(_clientHelloWrites)
: std::nullopt,
.clientHelloAcceptedBytes = clientHelloKnown
? std::make_optional(_clientHelloAcceptedBytes)
: std::nullopt,
.clientHelloFragmentSplit = _clientHelloFragmented
? std::make_optional(_clientHelloFragmentSplit)
: std::nullopt,
.clientHelloFragmentDelayMs = _clientHelloFragmented
? std::make_optional(_clientHelloFragmentDelayMs)
: std::nullopt,
.rxAfterClientHello = clientHelloKnown
? std::make_optional(_rxAfterClientHello)
: std::nullopt,
// Only for the case that has been unreadable so far: the hello went out
// and nothing came back. Then it matters whether the socket is still
// connected, whether bytes are sitting in it unread, and whether it
// ever announced any - a silent network and an answer this process
// never picked up produce the same zero without these.
.socketState = (clientHelloKnown && !_rxAfterClientHello && _transport)
? std::make_optional(int(_transport->state()))
: std::nullopt,
.socketBytesAvailable = (clientHelloKnown
&& !_rxAfterClientHello
&& _transport)
? std::make_optional(_transport->bytesAvailable())
: std::nullopt,
.readNotifications = (clientHelloKnown && !_rxAfterClientHello)
? std::make_optional(_readNotifications)
: std::nullopt,
.rxClass = clientHelloKnown ? responseClass() : QString(),
.block = (severity == ProxyDiagnosticsSeverity::Error)
? blockToken()
: QString(),
.tlsRecordType = _rxAfterClientHello
? responseRecordType()
: QString(),
.tlsRecordVersion = _rxAfterClientHello
? responseRecordVersion()
: QString(),
.tlsRecordLength = _rxAfterClientHello
? responseRecordLength()
: std::nullopt,
.responsePrefixHash = _rxAfterClientHello
? responsePrefixHash()
: QString(),
.sniLength = clientHelloKnown
? std::make_optional(int(domain.size()))
: std::nullopt,
.sniHash = clientHelloKnown
? QString::fromLatin1(domainHash.toHex().left(16))
: QString(),
.clientHelloTimestamp = _clientHelloTimestamp
? std::make_optional(_clientHelloTimestamp)
: std::nullopt,
.clockReference = clockReferenceName(),
.clockSkew = _clientHelloTimestamp
? std::make_optional(_clockSkew)
: std::nullopt,
.parserStage = proxyTransportFailure().parserStage,
.closeOrigin = (_closeOrigin == ProxyCloseOrigin::None)
? std::optional<ProxyCloseOrigin>()
: std::make_optional(_closeOrigin),
.tcpMs = (_tcpConnectedAt && _mtproxyAttemptStartedAt)
? std::make_optional(_tcpConnectedAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.firstRxMs = (_firstRxAt && _mtproxyAttemptStartedAt)
? std::make_optional(_firstRxAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.serverHelloMs = (_serverHelloAt && _mtproxyAttemptStartedAt)
? std::make_optional(_serverHelloAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.appDataMs = (_firstAppDataAt && _mtproxyAttemptStartedAt)
? std::make_optional(_firstAppDataAt - _mtproxyAttemptStartedAt)
: std::nullopt,
.totalMs = _mtproxyAttemptStartedAt
? std::make_optional(now - _mtproxyAttemptStartedAt)
: std::nullopt,
.traceSchema = 2,
});
}
} // namespace MTP::details