diff --git a/src/net_processing.cpp b/src/net_processing.cpp --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -67,6 +67,14 @@ // SHA256("main address relay")[0:8] static const uint64_t RANDOMIZER_ID_ADDRESS_RELAY = 0x3cac0035b5866b90ULL; +/// Age after which a stale block will no longer be served if requested as +/// protection against fingerprinting. Set to one month, denominated in seconds. +static const int STALE_RELAY_AGE_LIMIT = 30 * 24 * 60 * 60; + +/// Age after which a block is considered historical for purposes of rate +/// limiting block relay. Set to one week, denominated in seconds. +static const int HISTORICAL_BLOCK_AGE = 7 * 24 * 60 * 60; + // Internal stuff namespace { /** Number of nodes with fSyncStarted. */ @@ -924,6 +932,20 @@ // blockchain -> download logic notification // +// To prevent fingerprinting attacks, only send blocks/headers outside of the +// active chain if they are no more than a month older (both in time, and in +// best equivalent proof of work) than the best header chain we know about. +static bool StaleBlockRequestAllowed(const CBlockIndex *pindex, + const Consensus::Params &consensusParams) { + AssertLockHeld(cs_main); + return (pindexBestHeader != nullptr) && + (pindexBestHeader->GetBlockTime() - pindex->GetBlockTime() < + STALE_RELAY_AGE_LIMIT) && + (GetBlockProofEquivalentTime(*pindexBestHeader, *pindex, + *pindexBestHeader, consensusParams) < + STALE_RELAY_AGE_LIMIT); +} + PeerLogicValidation::PeerLogicValidation(CConnman *connmanIn, CScheduler &scheduler) : connman(connmanIn), m_stale_tip_check_time(0) { @@ -1254,21 +1276,9 @@ if (chainActive.Contains(mi->second)) { send = true; } else { - static const int nOneMonth = 30 * 24 * 60 * 60; - // To prevent fingerprinting attacks, only send blocks - // outside of the active chain if they are valid, and no - // more than a month older (both in time, and in best - // equivalent proof of work) than the best header chain - // we know about. send = mi->second->IsValid(BlockValidity::SCRIPTS) && - (pindexBestHeader != nullptr) && - (pindexBestHeader->GetBlockTime() - - mi->second->GetBlockTime() < - nOneMonth) && - (GetBlockProofEquivalentTime( - *pindexBestHeader, *mi->second, - *pindexBestHeader, - consensusParams) < nOneMonth); + StaleBlockRequestAllowed(mi->second, + consensusParams); if (!send) { LogPrintf("%s: ignoring request from peer=%i for " "old block that isn't in the main " @@ -1279,15 +1289,13 @@ } // Disconnect node in case we have reached the outbound limit - // for serving historical blocks never disconnect whitelisted - // nodes. - // assume > 1 week = historical - static const int nOneWeek = 7 * 24 * 60 * 60; + // for serving historical blocks. + // Never disconnect whitelisted nodes. if (send && connman->OutboundTargetReached(true) && (((pindexBestHeader != nullptr) && (pindexBestHeader->GetBlockTime() - mi->second->GetBlockTime() > - nOneWeek)) || + HISTORICAL_BLOCK_AGE)) || inv.type == MSG_FILTERED_BLOCK) && !pfrom->fWhitelisted) { LogPrint(BCLog::NET, "historical block serving limit " @@ -2343,6 +2351,14 @@ return true; } pindex = (*mi).second; + + if (!chainActive.Contains(pindex) && + !StaleBlockRequestAllowed(pindex, chainparams.GetConsensus())) { + LogPrintf("%s: ignoring request from peer=%i for old block " + "header that isn't in the main chain\n", + __func__, pfrom->GetId()); + return true; + } } else { // Find the last block the caller has in the main chain pindex = FindForkInGlobalIndex(chainActive, locator); diff --git a/test/functional/p2p-fingerprint.py b/test/functional/p2p-fingerprint.py new file mode 100755 --- /dev/null +++ b/test/functional/p2p-fingerprint.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# Copyright (c) 2017 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test various fingerprinting protections. + +If an stale block more than a month old or its header are requested by a peer, +the node should pretend that it does not have it to avoid fingerprinting. +""" + +import time + +from test_framework.blocktools import (create_block, create_coinbase) +from test_framework.mininode import ( + CInv, + NetworkThread, + NodeConn, + NodeConnCB, + msg_headers, + msg_block, + msg_getdata, + msg_getheaders, + wait_until, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + p2p_port, +) + + +class P2PFingerprintTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + + # Build a chain of blocks on top of given one + def build_chain(self, nblocks, prev_hash, prev_height, prev_median_time): + blocks = [] + for _ in range(nblocks): + coinbase = create_coinbase(prev_height + 1) + block_time = prev_median_time + 1 + block = create_block(int(prev_hash, 16), coinbase, block_time) + block.solve() + + blocks.append(block) + prev_hash = block.hash + prev_height += 1 + prev_median_time = block_time + return blocks + + # Send a getdata request for a given block hash + def send_block_request(self, block_hash, node): + msg = msg_getdata() + # 2 == "Block" + msg.inv.append(CInv(2, block_hash)) + node.send_message(msg) + + # Send a getheaders request for a given single block hash + def send_header_request(self, block_hash, node): + msg = msg_getheaders() + msg.hashstop = block_hash + node.send_message(msg) + + # Check whether last block received from node has a given hash + def last_block_equals(self, expected_hash, node): + block_msg = node.last_message.get("block") + return block_msg and block_msg.block.rehash() == expected_hash + + # Check whether last block header received from node has a given hash + def last_header_equals(self, expected_hash, node): + headers_msg = node.last_message.get("headers") + return (headers_msg and + headers_msg.headers and + headers_msg.headers[0].rehash() == expected_hash) + + # Checks that stale blocks timestamped more than a month ago are not served + # by the node while recent stale blocks and old active chain blocks are. + # This does not currently test that stale blocks timestamped within the + # last month but that have over a month's worth of work are also withheld. + def run_test(self): + node0 = NodeConnCB() + + connections = [] + connections.append( + NodeConn('127.0.0.1', p2p_port(0), self.nodes[0], node0)) + node0.add_connection(connections[0]) + + NetworkThread().start() + node0.wait_for_verack() + + # Set node time to 60 days ago + self.nodes[0].setmocktime(int(time.time()) - 60 * 24 * 60 * 60) + + # Generating a chain of 10 blocks + block_hashes = self.nodes[0].generate(nblocks=10) + + # Create longer chain starting 2 blocks before current tip + height = len(block_hashes) - 2 + block_hash = block_hashes[height - 1] + block_time = self.nodes[0].getblockheader(block_hash)["mediantime"] + 1 + new_blocks = self.build_chain(5, block_hash, height, block_time) + + # Force reorg to a longer chain + node0.send_message(msg_headers(new_blocks)) + node0.wait_for_getdata() + for block in new_blocks: + node0.send_and_ping(msg_block(block)) + + # Check that reorg succeeded + assert_equal(self.nodes[0].getblockcount(), 13) + + stale_hash = int(block_hashes[-1], 16) + + # Check that getdata request for stale block succeeds + self.send_block_request(stale_hash, node0) + + def test_function(): return self.last_block_equals(stale_hash, node0) + wait_until(test_function, timeout=3) + + # Check that getheader request for stale block header succeeds + self.send_header_request(stale_hash, node0) + + def test_function(): return self.last_header_equals(stale_hash, node0) + wait_until(test_function, timeout=3) + + # Longest chain is extended so stale is much older than chain tip + self.nodes[0].setmocktime(0) + tip = self.nodes[0].generate(nblocks=1)[0] + assert_equal(self.nodes[0].getblockcount(), 14) + + # Send getdata & getheaders to refresh last received getheader message + block_hash = int(tip, 16) + self.send_block_request(block_hash, node0) + self.send_header_request(block_hash, node0) + node0.sync_with_ping() + + # Request for very old stale block should now fail + self.send_block_request(stale_hash, node0) + time.sleep(3) + assert not self.last_block_equals(stale_hash, node0) + + # Request for very old stale block header should now fail + self.send_header_request(stale_hash, node0) + time.sleep(3) + assert not self.last_header_equals(stale_hash, node0) + + # Verify we can fetch very old blocks and headers on the active chain + block_hash = int(block_hashes[2], 16) + self.send_block_request(block_hash, node0) + self.send_header_request(block_hash, node0) + node0.sync_with_ping() + + self.send_block_request(block_hash, node0) + + def test_function(): return self.last_block_equals(block_hash, node0) + wait_until(test_function, timeout=3) + + self.send_header_request(block_hash, node0) + + def test_function(): return self.last_header_equals(block_hash, node0) + wait_until(test_function, timeout=3) + + +if __name__ == '__main__': + P2PFingerprintTest().main() diff --git a/test/functional/test_framework/mininode.py b/test/functional/test_framework/mininode.py --- a/test/functional/test_framework/mininode.py +++ b/test/functional/test_framework/mininode.py @@ -1082,8 +1082,8 @@ class msg_headers(): command = b"headers" - def __init__(self): - self.headers = [] + def __init__(self, headers=None): + self.headers = headers if headers is not None else [] def deserialize(self, f): # comment in bitcoind indicates these should be deserialized as blocks diff --git a/test/functional/timing.json b/test/functional/timing.json --- a/test/functional/timing.json +++ b/test/functional/timing.json @@ -1,51 +1,51 @@ [ { "name": "abandonconflict.py", - "time": 15 + "time": 11 }, { "name": "abc-checkdatasig-activation.py", - "time": 22 + "time": 3 }, { "name": "abc-cmdline.py", - "time": 10 + "time": 7 }, { "name": "abc-finalize-block.py", - "time": 7 + "time": 5 }, { "name": "abc-high_priority_transaction.py", - "time": 20 + "time": 6 }, { "name": "abc-magnetic-anomaly-activation.py", - "time": 6 + "time": 5 }, { "name": "abc-magnetic-anomaly-mining.py", - "time": 29 + "time": 3 }, { "name": "abc-mempool-accept-txn.py", - "time": 13 + "time": 3 }, { "name": "abc-p2p-compactblocks.py", - "time": 411 + "time": 253 }, { "name": "abc-p2p-fullblocktest.py", - "time": 79 + "time": 31 }, { "name": "abc-parkedchain.py", - "time": 13 + "time": 4 }, { "name": "abc-replay-protection.py", - "time": 4 + "time": 3 }, { "name": "abc-rpc.py", @@ -53,47 +53,47 @@ }, { "name": "abc-sync-chain.py", - "time": 3 + "time": 2 }, { "name": "abc-transaction-ordering.py", - "time": 6 + "time": 5 }, { "name": "assumevalid.py", - "time": 12 + "time": 8 }, { "name": "bip65-cltv-p2p.py", - "time": 5 + "time": 4 }, { "name": "bip68-112-113-p2p.py", - "time": 39 + "time": 14 }, { "name": "bip68-sequence.py", - "time": 64 + "time": 12 }, { "name": "bipdersig-p2p.py", - "time": 5 + "time": 3 }, { "name": "bitcoin_cli.py", - "time": 13 + "time": 2 }, { "name": "blockchain.py", - "time": 40 + "time": 10 }, { "name": "dbcrash.py", - "time": 1320 + "time": 537 }, { "name": "decodescript.py", - "time": 3 + "time": 2 }, { "name": "disablewallet.py", @@ -101,47 +101,47 @@ }, { "name": "disconnect_ban.py", - "time": 20 + "time": 7 }, { "name": "example_test.py", - "time": 3 + "time": 2 }, { "name": "fundrawtransaction.py", - "time": 47 + "time": 31 }, { "name": "getblocktemplate_longpoll.py", - "time": 68 + "time": 67 }, { "name": "getchaintips.py", - "time": 28 + "time": 3 }, { "name": "httpbasics.py", - "time": 4 + "time": 2 }, { "name": "import-rescan.py", - "time": 16 + "time": 3 }, { "name": "importmulti.py", - "time": 26 + "time": 5 }, { "name": "importprunedfunds.py", - "time": 4 + "time": 2 }, { "name": "invalidateblock.py", - "time": 8 + "time": 7 }, { "name": "invalidblockrequest.py", - "time": 4 + "time": 3 }, { "name": "invalidtxrequest.py", @@ -149,15 +149,15 @@ }, { "name": "keypool-topup.py", - "time": 32 + "time": 8 }, { "name": "keypool.py", - "time": 37 + "time": 6 }, { "name": "listsinceblock.py", - "time": 5 + "time": 3 }, { "name": "listtransactions.py", @@ -165,135 +165,139 @@ }, { "name": "maxuploadtarget.py", - "time": 26 + "time": 21 }, { "name": "mempool_limit.py", - "time": 10 + "time": 4 }, { "name": "mempool_packages.py", - "time": 26 + "time": 9 }, { "name": "mempool_persist.py", - "time": 46 + "time": 17 }, { "name": "mempool_reorg.py", - "time": 8 + "time": 6 }, { "name": "mempool_resurrect_test.py", - "time": 4 + "time": 2 }, { "name": "mempool_spendcoinbase.py", - "time": 4 + "time": 2 }, { "name": "merkle_blocks.py", - "time": 4 + "time": 3 }, { "name": "minchainwork.py", - "time": 16 + "time": 5 }, { "name": "mining.py", - "time": 4 + "time": 2 }, { "name": "multi_rpc.py", - "time": 10 + "time": 4 }, { "name": "multiwallet.py", - "time": 8 + "time": 5 }, { "name": "net.py", - "time": 3 + "time": 2 }, { "name": "notifications.py", - "time": 20 + "time": 5 }, { "name": "nulldummy.py", - "time": 5 + "time": 2 }, { "name": "p2p-acceptblock.py", - "time": 5 + "time": 4 }, { "name": "p2p-compactblocks.py", - "time": 30 + "time": 12 }, { "name": "p2p-feefilter.py", - "time": 19 + "time": 23 + }, + { + "name": "p2p-fingerprint.py", + "time": 8 }, { "name": "p2p-fullblocktest.py", - "time": 133 + "time": 97 }, { "name": "p2p-leaktests.py", - "time": 8 + "time": 7 }, { "name": "p2p-mempool.py", - "time": 12 + "time": 2 }, { "name": "p2p-timeouts.py", - "time": 65 + "time": 64 }, { "name": "preciousblock.py", - "time": 10 + "time": 3 }, { "name": "prioritise_transaction.py", - "time": 9 + "time": 4 }, { "name": "proxy_test.py", - "time": 7 + "time": 3 }, { "name": "pruning.py", - "time": 1527 + "time": 502 }, { "name": "rawtransactions.py", - "time": 38 + "time": 16 }, { "name": "reindex.py", - "time": 27 + "time": 12 }, { "name": "resendwallettransactions.py", - "time": 14 + "time": 5 }, { "name": "rest.py", - "time": 32 + "time": 6 }, { "name": "rpcbind_test.py", - "time": 27 + "time": 24 }, { "name": "rpcnamedargs.py", - "time": 3 + "time": 2 }, { "name": "sendheaders.py", - "time": 42 + "time": 13 }, { "name": "signmessages.py", @@ -305,58 +309,58 @@ }, { "name": "txn_clone.py", - "time": 5 + "time": 3 }, { "name": "txn_clone.py --mineblock", - "time": 8 + "time": 3 }, { "name": "txn_doublespend.py", - "time": 24 + "time": 3 }, { "name": "txn_doublespend.py --mineblock", - "time": 16 + "time": 3 }, { "name": "uptime.py", - "time": 3 + "time": 2 }, { "name": "wallet.py", - "time": 75 + "time": 31 }, { "name": "wallet_accounts.py", - "time": 26 + "time": 2 }, { "name": "wallet_dump.py", - "time": 12 + "time": 5 }, { "name": "wallet_encryption.py", - "time": 27 + "time": 7 }, { "name": "wallet_hd.py", - "time": 142 + "time": 17 }, { "name": "wallet_receivedby.py", - "time": 21 + "time": 12 }, { "name": "walletbackup.py", - "time": 130 + "time": 100 }, { "name": "zapwallettxes.py", - "time": 26 + "time": 10 }, { "name": "zmq_test.py", - "time": 7 + "time": 11 } ] \ No newline at end of file