From 94db966a3bb52a3677eb5f762447202ed3889f0f Mon Sep 17 00:00:00 2001 From: Martin Zumsande Date: Mon, 22 Sep 2025 16:15:48 -0400 Subject: [PATCH 1/2] net: use generic network key for addrcache The generic key can also be used in other places where behavior between different network identities should be uncorrelated to avoid fingerprinting. This also changes RANDOMIZER_ID - since it is not being persisted to disk, there are no compatibility issues. --- src/net.cpp | 28 +++++++++++++++++--------- src/net.h | 5 +++++ src/test/denialofservice_tests.cpp | 18 +++++++++++------ src/test/fuzz/p2p_headers_presync.cpp | 2 +- src/test/fuzz/util/net.h | 4 ++++ src/test/net_peer_connection_tests.cpp | 3 ++- src/test/net_tests.cpp | 27 ++++++++++++++++--------- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/net.cpp b/src/net.cpp index 50988114d0b..440b04b652d 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -108,7 +108,7 @@ const std::string NET_MESSAGE_TYPE_OTHER = "*other*"; static const uint64_t RANDOMIZER_ID_NETGROUP = 0x6c0edd8036ef4036ULL; // SHA256("netgroup")[0:8] static const uint64_t RANDOMIZER_ID_LOCALHOSTNONCE = 0xd93e69e2bbfa5735ULL; // SHA256("localhostnonce")[0:8] -static const uint64_t RANDOMIZER_ID_ADDRCACHE = 0x1cf2e4ddd306dda9ULL; // SHA256("addrcache")[0:8] +static const uint64_t RANDOMIZER_ID_NETWORKKEY = 0x0e8a2b136c592a7dULL; // SHA256("networkkey")[0:8] // // Global state variables // @@ -530,6 +530,13 @@ CNode* CConnman::ConnectNode(CAddress addrConnect, const char *pszDest, bool fCo if (!addr_bind.IsValid()) { addr_bind = GetBindAddress(*sock); } + uint64_t network_id = GetDeterministicRandomizer(RANDOMIZER_ID_NETWORKKEY) + .Write(target_addr.GetNetClass()) + .Write(addr_bind.GetAddrBytes()) + // For outbound connections, the port of the bound address is randomly + // assigned by the OS and would therefore not be useful for seeding. + .Write(0) + .Finalize(); CNode* pnode = new CNode(id, std::move(sock), target_addr, @@ -539,6 +546,7 @@ CNode* CConnman::ConnectNode(CAddress addrConnect, const char *pszDest, bool fCo pszDest ? pszDest : "", conn_type, /*inbound_onion=*/false, + network_id, CNodeOptions{ .permission_flags = permission_flags, .i2p_sam_session = std::move(i2p_transient_session), @@ -1832,6 +1840,11 @@ void CConnman::CreateNodeFromAcceptedSocket(std::unique_ptr&& sock, ServiceFlags local_services = GetLocalServices(); const bool use_v2transport(local_services & NODE_P2P_V2); + uint64_t network_id = GetDeterministicRandomizer(RANDOMIZER_ID_NETWORKKEY) + .Write(inbound_onion ? NET_ONION : addr.GetNetClass()) + .Write(addr_bind.GetAddrBytes()) + .Write(addr_bind.GetPort()) // inbound connections use bind port + .Finalize(); CNode* pnode = new CNode(id, std::move(sock), CAddress{addr, NODE_NONE}, @@ -1841,6 +1854,7 @@ void CConnman::CreateNodeFromAcceptedSocket(std::unique_ptr&& sock, /*addrNameIn=*/"", ConnectionType::INBOUND, inbound_onion, + network_id, CNodeOptions{ .permission_flags = permission_flags, .prefer_evict = discouraged, @@ -3519,15 +3533,9 @@ std::vector CConnman::GetAddressesUnsafe(size_t max_addresses, size_t std::vector CConnman::GetAddresses(CNode& requestor, size_t max_addresses, size_t max_pct) { auto local_socket_bytes = requestor.addrBind.GetAddrBytes(); - uint64_t cache_id = GetDeterministicRandomizer(RANDOMIZER_ID_ADDRCACHE) - .Write(requestor.ConnectedThroughNetwork()) - .Write(local_socket_bytes) - // For outbound connections, the port of the bound address is randomly - // assigned by the OS and would therefore not be useful for seeding. - .Write(requestor.IsInboundConn() ? requestor.addrBind.GetPort() : 0) - .Finalize(); + uint64_t network_id = requestor.m_network_key; const auto current_time = GetTime(); - auto r = m_addr_response_caches.emplace(cache_id, CachedAddrResponse{}); + auto r = m_addr_response_caches.emplace(network_id, CachedAddrResponse{}); CachedAddrResponse& cache_entry = r.first->second; if (cache_entry.m_cache_entry_expiration < current_time) { // If emplace() added new one it has expiration 0. cache_entry.m_addrs_response_cache = GetAddressesUnsafe(max_addresses, max_pct, /*network=*/std::nullopt); @@ -3804,6 +3812,7 @@ CNode::CNode(NodeId idIn, const std::string& addrNameIn, ConnectionType conn_type_in, bool inbound_onion, + uint64_t network_key, CNodeOptions&& node_opts) : m_transport{MakeTransport(idIn, node_opts.use_v2transport, conn_type_in == ConnectionType::INBOUND)}, m_permission_flags{node_opts.permission_flags}, @@ -3816,6 +3825,7 @@ CNode::CNode(NodeId idIn, m_inbound_onion{inbound_onion}, m_prefer_evict{node_opts.prefer_evict}, nKeyedNetGroup{nKeyedNetGroupIn}, + m_network_key{network_key}, m_conn_type{conn_type_in}, id{idIn}, nLocalHostNonce{nLocalHostNonceIn}, diff --git a/src/net.h b/src/net.h index afbcc52bd0f..8749adddcd5 100644 --- a/src/net.h +++ b/src/net.h @@ -738,6 +738,10 @@ public: std::atomic_bool fPauseRecv{false}; std::atomic_bool fPauseSend{false}; + /** Network key used to prevent fingerprinting our node across networks. + * Influenced by the network and the bind address (+ bind port for inbounds) */ + const uint64_t m_network_key; + const ConnectionType m_conn_type; /** Move all messages from the received queue to the processing queue. */ @@ -889,6 +893,7 @@ public: const std::string& addrNameIn, ConnectionType conn_type_in, bool inbound_onion, + uint64_t network_key, CNodeOptions&& node_opts = {}); CNode(const CNode&) = delete; CNode& operator=(const CNode&) = delete; diff --git a/src/test/denialofservice_tests.cpp b/src/test/denialofservice_tests.cpp index 16fdd4b7b65..0dc0323028e 100644 --- a/src/test/denialofservice_tests.cpp +++ b/src/test/denialofservice_tests.cpp @@ -62,7 +62,8 @@ BOOST_AUTO_TEST_CASE(outbound_slow_chain_eviction) CAddress(), /*addrNameIn=*/"", ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/0}; connman.Handshake( /*node=*/dummyNode1, @@ -128,7 +129,8 @@ void AddRandomOutboundPeer(NodeId& id, std::vector& vNodes, PeerManager& CAddress(), /*addrNameIn=*/"", connType, - /*inbound_onion=*/false}); + /*inbound_onion=*/false, + /*network_key=*/0}); CNode &node = *vNodes.back(); node.SetCommonVersion(PROTOCOL_VERSION); @@ -327,7 +329,8 @@ BOOST_AUTO_TEST_CASE(peer_discouragement) CAddress(), /*addrNameIn=*/"", ConnectionType::INBOUND, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/1}; nodes[0]->SetCommonVersion(PROTOCOL_VERSION); peerLogic->InitializeNode(*nodes[0], NODE_NETWORK); nodes[0]->fSuccessfullyConnected = true; @@ -347,7 +350,8 @@ BOOST_AUTO_TEST_CASE(peer_discouragement) CAddress(), /*addrNameIn=*/"", ConnectionType::INBOUND, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/1}; nodes[1]->SetCommonVersion(PROTOCOL_VERSION); peerLogic->InitializeNode(*nodes[1], NODE_NETWORK); nodes[1]->fSuccessfullyConnected = true; @@ -377,7 +381,8 @@ BOOST_AUTO_TEST_CASE(peer_discouragement) CAddress(), /*addrNameIn=*/"", ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/2}; nodes[2]->SetCommonVersion(PROTOCOL_VERSION); peerLogic->InitializeNode(*nodes[2], NODE_NETWORK); nodes[2]->fSuccessfullyConnected = true; @@ -419,7 +424,8 @@ BOOST_AUTO_TEST_CASE(DoS_bantime) CAddress(), /*addrNameIn=*/"", ConnectionType::INBOUND, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/1}; dummyNode.SetCommonVersion(PROTOCOL_VERSION); peerLogic->InitializeNode(dummyNode, NODE_NETWORK); dummyNode.fSuccessfullyConnected = true; diff --git a/src/test/fuzz/p2p_headers_presync.cpp b/src/test/fuzz/p2p_headers_presync.cpp index 9aebac30557..c6842a35849 100644 --- a/src/test/fuzz/p2p_headers_presync.cpp +++ b/src/test/fuzz/p2p_headers_presync.cpp @@ -70,7 +70,7 @@ void HeadersSyncSetup::ResetAndInitialize() for (auto conn_type : conn_types) { CAddress addr{}; - m_connections.push_back(new CNode(id++, nullptr, addr, 0, 0, addr, "", conn_type, false)); + m_connections.push_back(new CNode(id++, nullptr, addr, 0, 0, addr, "", conn_type, false, 0)); CNode& p2p_node = *m_connections.back(); connman.Handshake( diff --git a/src/test/fuzz/util/net.h b/src/test/fuzz/util/net.h index 698001a7f15..381103aa8b6 100644 --- a/src/test/fuzz/util/net.h +++ b/src/test/fuzz/util/net.h @@ -239,6 +239,8 @@ auto ConsumeNode(FuzzedDataProvider& fuzzed_data_provider, const std::optional(); + NetPermissionFlags permission_flags = ConsumeWeakEnum(fuzzed_data_provider, ALL_NET_PERMISSION_FLAGS); if constexpr (ReturnUniquePtr) { return std::make_unique(node_id, @@ -250,6 +252,7 @@ auto ConsumeNode(FuzzedDataProvider& fuzzed_data_provider, const std::optional& nodes, PeerManager& peerman, Connm CAddress{}, /*addrNameIn=*/"", conn_type, - /*inbound_onion=*/inbound_onion}); + /*inbound_onion=*/inbound_onion, + /*network_key=*/0}); CNode& node = *nodes.back(); node.SetCommonVersion(PROTOCOL_VERSION); diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 0036d94c2fa..711b067ad26 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -67,7 +67,8 @@ BOOST_AUTO_TEST_CASE(cnode_simple_test) CAddress(), pszDest, ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false); + /*inbound_onion=*/false, + /*network_key=*/0); BOOST_CHECK(pnode1->IsFullOutboundConn() == true); BOOST_CHECK(pnode1->IsManualConn() == false); BOOST_CHECK(pnode1->IsBlockOnlyConn() == false); @@ -85,7 +86,8 @@ BOOST_AUTO_TEST_CASE(cnode_simple_test) CAddress(), pszDest, ConnectionType::INBOUND, - /*inbound_onion=*/false); + /*inbound_onion=*/false, + /*network_key=*/1); BOOST_CHECK(pnode2->IsFullOutboundConn() == false); BOOST_CHECK(pnode2->IsManualConn() == false); BOOST_CHECK(pnode2->IsBlockOnlyConn() == false); @@ -103,7 +105,8 @@ BOOST_AUTO_TEST_CASE(cnode_simple_test) CAddress(), pszDest, ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false); + /*inbound_onion=*/false, + /*network_key=*/2); BOOST_CHECK(pnode3->IsFullOutboundConn() == true); BOOST_CHECK(pnode3->IsManualConn() == false); BOOST_CHECK(pnode3->IsBlockOnlyConn() == false); @@ -121,7 +124,8 @@ BOOST_AUTO_TEST_CASE(cnode_simple_test) CAddress(), pszDest, ConnectionType::INBOUND, - /*inbound_onion=*/true); + /*inbound_onion=*/true, + /*network_key=*/3); BOOST_CHECK(pnode4->IsFullOutboundConn() == false); BOOST_CHECK(pnode4->IsManualConn() == false); BOOST_CHECK(pnode4->IsBlockOnlyConn() == false); @@ -613,7 +617,8 @@ BOOST_AUTO_TEST_CASE(ipv4_peer_with_ipv6_addrMe_test) CAddress{}, /*pszDest=*/std::string{}, ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false); + /*inbound_onion=*/false, + /*network_key=*/0); pnode->fSuccessfullyConnected.store(true); // the peer claims to be reaching us via IPv6 @@ -667,7 +672,8 @@ BOOST_AUTO_TEST_CASE(get_local_addr_for_peer_port) /*addrBindIn=*/CService{}, /*addrNameIn=*/std::string{}, /*conn_type_in=*/ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/0}; peer_out.fSuccessfullyConnected = true; peer_out.SetAddrLocal(peer_us); @@ -688,7 +694,8 @@ BOOST_AUTO_TEST_CASE(get_local_addr_for_peer_port) /*addrBindIn=*/CService{}, /*addrNameIn=*/std::string{}, /*conn_type_in=*/ConnectionType::INBOUND, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/1}; peer_in.fSuccessfullyConnected = true; peer_in.SetAddrLocal(peer_us); @@ -825,7 +832,8 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message) /*addrBindIn=*/CService{}, /*addrNameIn=*/std::string{}, /*conn_type_in=*/ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false}; + /*inbound_onion=*/false, + /*network_key=*/2}; const uint64_t services{NODE_NETWORK | NODE_WITNESS}; const int64_t time{0}; @@ -900,7 +908,8 @@ BOOST_AUTO_TEST_CASE(advertise_local_address) CAddress{}, /*pszDest=*/std::string{}, ConnectionType::OUTBOUND_FULL_RELAY, - /*inbound_onion=*/false); + /*inbound_onion=*/false, + /*network_key=*/0); }; g_reachable_nets.Add(NET_CJDNS); From 0f7d4ee4e8281ed141a6ebb7e0edee7b864e4dcf Mon Sep 17 00:00:00 2001 From: Martin Zumsande Date: Wed, 17 Sep 2025 23:58:09 -0400 Subject: [PATCH 2/2] p2p: Use different inbound inv timer per network Currently nodes schedule their invs to all inbound peers at the same time. It is trivial to make use this timing pattern for fingerprinting identities on different networks. Using a separate timers for each network will make the fingerprinting harder. --- src/net_processing.cpp | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 336669a8545..8a39cb587a9 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -807,7 +807,7 @@ private: uint32_t GetFetchFlags(const Peer& peer) const; - std::atomic m_next_inv_to_inbounds{0us}; + std::map m_next_inv_to_inbounds_per_network_key GUARDED_BY(g_msgproc_mutex); /** Number of nodes with fSyncStarted. */ int nSyncStarted GUARDED_BY(cs_main) = 0; @@ -837,12 +837,14 @@ private: /** * For sending `inv`s to inbound peers, we use a single (exponentially - * distributed) timer for all peers. If we used a separate timer for each + * distributed) timer for all peers with the same network key. If we used a separate timer for each * peer, a spy node could make multiple inbound connections to us to - * accurately determine when we received the transaction (and potentially - * determine the transaction's origin). */ + * accurately determine when we received a transaction (and potentially + * determine the transaction's origin). Each network key has its own timer + * to make fingerprinting harder. */ std::chrono::microseconds NextInvToInbounds(std::chrono::microseconds now, - std::chrono::seconds average_interval) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); + std::chrono::seconds average_interval, + uint64_t network_key) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); // All of the following cache a recent block, and are protected by m_most_recent_block_mutex @@ -1143,15 +1145,15 @@ static bool CanServeWitnesses(const Peer& peer) } std::chrono::microseconds PeerManagerImpl::NextInvToInbounds(std::chrono::microseconds now, - std::chrono::seconds average_interval) + std::chrono::seconds average_interval, + uint64_t network_key) { - if (m_next_inv_to_inbounds.load() < now) { - // If this function were called from multiple threads simultaneously - // it would possible that both update the next send variable, and return a different result to their caller. - // This is not possible in practice as only the net processing thread invokes this function. - m_next_inv_to_inbounds = now + m_rng.rand_exp_duration(average_interval); + auto [it, inserted] = m_next_inv_to_inbounds_per_network_key.try_emplace(network_key, 0us); + auto& timer{it->second}; + if (timer < now) { + timer = now + m_rng.rand_exp_duration(average_interval); } - return m_next_inv_to_inbounds; + return timer; } bool PeerManagerImpl::IsBlockRequested(const uint256& hash) @@ -5711,7 +5713,7 @@ bool PeerManagerImpl::SendMessages(CNode* pto) if (tx_relay->m_next_inv_send_time < current_time) { fSendTrickle = true; if (pto->IsInboundConn()) { - tx_relay->m_next_inv_send_time = NextInvToInbounds(current_time, INBOUND_INVENTORY_BROADCAST_INTERVAL); + tx_relay->m_next_inv_send_time = NextInvToInbounds(current_time, INBOUND_INVENTORY_BROADCAST_INTERVAL, pto->m_network_key); } else { tx_relay->m_next_inv_send_time = current_time + m_rng.rand_exp_duration(OUTBOUND_INVENTORY_BROADCAST_INTERVAL); }