Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test/BIP324: functional tests for v2 P2P encryption #11

Merged
merged 15 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/net.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1833,7 +1833,7 @@ void CConnman::CreateNodeFromAcceptedSocket(std::unique_ptr<Sock>&& sock,
RandAddEvent((uint32_t)id);
}

bool CConnman::AddConnection(const std::string& address, ConnectionType conn_type)
bool CConnman::AddConnection(const std::string& address, ConnectionType conn_type, bool use_v2transport = false)
{
AssertLockNotHeld(m_unused_i2p_sessions_mutex);
std::optional<int> max_connections;
Expand Down Expand Up @@ -1866,7 +1866,7 @@ bool CConnman::AddConnection(const std::string& address, ConnectionType conn_typ
CSemaphoreGrant grant(*semOutbound, true);
if (!grant) return false;

OpenNetworkConnection(CAddress(), false, std::move(grant), address.c_str(), conn_type, /*use_v2transport=*/false);
OpenNetworkConnection(CAddress(), false, std::move(grant), address.c_str(), conn_type, /*use_v2transport=*/use_v2transport);
return true;
}

Expand Down
3 changes: 2 additions & 1 deletion src/net.h
Original file line number Diff line number Diff line change
Expand Up @@ -1184,13 +1184,14 @@ class CConnman
* @param[in] address Address of node to try connecting to
* @param[in] conn_type ConnectionType::OUTBOUND, ConnectionType::BLOCK_RELAY,
* ConnectionType::ADDR_FETCH or ConnectionType::FEELER
* @param[in] use_v2transport Set to true if node attempts to connect using BIP 324 v2 transport protocol.
* @return bool Returns false if there are no available
* slots for this connection:
* - conn_type not a supported ConnectionType
* - Max total outbound connection capacity filled
* - Max connection capacity for type is filled
*/
bool AddConnection(const std::string& address, ConnectionType conn_type) EXCLUSIVE_LOCKS_REQUIRED(!m_unused_i2p_sessions_mutex);
bool AddConnection(const std::string& address, ConnectionType conn_type, bool use_v2transport) EXCLUSIVE_LOCKS_REQUIRED(!m_unused_i2p_sessions_mutex);

size_t GetNodeCount(ConnectionDirection) const;
uint32_t GetMappedAS(const CNetAddr& addr) const;
Expand Down
1 change: 1 addition & 0 deletions src/rpc/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "sendmsgtopeer", 0, "peer_id" },
{ "stop", 0, "wait" },
{ "addnode", 2, "v2transport" },
{ "addconnection", 2, "v2transport" },
};
// clang-format on

Expand Down
12 changes: 9 additions & 3 deletions src/rpc/net.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ static RPCHelpMan addconnection()
{
{"address", RPCArg::Type::STR, RPCArg::Optional::NO, "The IP address and port to attempt connecting to."},
{"connection_type", RPCArg::Type::STR, RPCArg::Optional::NO, "Type of connection to open (\"outbound-full-relay\", \"block-relay-only\", \"addr-fetch\" or \"feeler\")."},
{"v2transport", RPCArg::Type::BOOL, RPCArg::Default{false}, "Attempt to connect using BIP324 v2 transport protocol"},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
Expand All @@ -378,8 +379,8 @@ static RPCHelpMan addconnection()
{ RPCResult::Type::STR, "connection_type", "Type of connection opened." },
}},
RPCExamples{
HelpExampleCli("addconnection", "\"192.168.0.6:8333\" \"outbound-full-relay\"")
+ HelpExampleRpc("addconnection", "\"192.168.0.6:8333\" \"outbound-full-relay\"")
HelpExampleCli("addconnection", "\"192.168.0.6:8333\" \"outbound-full-relay\" true")
+ HelpExampleRpc("addconnection", "\"192.168.0.6:8333\" \"outbound-full-relay\" true")
},
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
{
Expand All @@ -401,11 +402,16 @@ static RPCHelpMan addconnection()
} else {
throw JSONRPCError(RPC_INVALID_PARAMETER, self.ToString());
}
bool use_v2transport = !request.params[2].isNull() && request.params[2].get_bool();

NodeContext& node = EnsureAnyNodeContext(request.context);
CConnman& connman = EnsureConnman(node);

const bool success = connman.AddConnection(address, conn_type);
if (use_v2transport && !(connman.GetLocalServices() & NODE_P2P_V2)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Error: Adding v2transport connections requires -v2transport init flag to be set.");
}

const bool success = connman.AddConnection(address, conn_type, use_v2transport);
if (!success) {
throw JSONRPCError(RPC_CLIENT_NODE_CAPACITY_REACHED, "Error: Already at capacity for specified connection type.");
}
Expand Down
3 changes: 1 addition & 2 deletions test/functional/feature_addrman.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
import re
import struct

from test_framework.messages import ser_uint256, hash256
from test_framework.messages import ser_uint256, hash256, MAGIC_BYTES
from test_framework.netutil import ADDRMAN_NEW_BUCKET_COUNT, ADDRMAN_TRIED_BUCKET_COUNT, ADDRMAN_BUCKET_SIZE
from test_framework.p2p import MAGIC_BYTES
from test_framework.test_framework import BitcoinTestFramework
from test_framework.test_node import ErrorMatch
from test_framework.util import assert_equal
Expand Down
2 changes: 1 addition & 1 deletion test/functional/feature_reindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"""

from test_framework.test_framework import BitcoinTestFramework
from test_framework.p2p import MAGIC_BYTES
from test_framework.messages import MAGIC_BYTES
from test_framework.util import assert_equal


Expand Down
85 changes: 85 additions & 0 deletions test/functional/p2p_v2_earlykeyresponse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# Copyright (c) 2022 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.

import random

from test_framework.test_framework import BitcoinTestFramework
from test_framework.crypto.ellswift import ellswift_create
from test_framework.p2p import P2PInterface
from test_framework.v2_p2p import EncryptedP2PState


class TestEncryptedP2PState(EncryptedP2PState):
""" Modify v2 P2P protocol functions for testing that "The responder waits until one byte is received which does
not match the 16 bytes consisting of the network magic followed by "version\x00\x00\x00\x00\x00"." (see BIP 324)

- if `send_net_magic` is True, send first 4 bytes of ellswift (match network magic) else send remaining 60 bytes
- `can_data_be_received` is a variable used to assert if data is received on recvbuf.
- v2 TestNode shouldn't respond back if we send V1_PREFIX and data shouldn't be received on recvbuf.
This state is represented using `can_data_be_received` = False.
- v2 TestNode responds back when mismatch from V1_PREFIX happens and data can be received on recvbuf.
This state is represented using `can_data_be_received` = True.
"""

def __init__(self):
super().__init__(initiating=True, net='regtest')
self.send_net_magic = True
self.can_data_be_received = False

def initiate_v2_handshake(self, garbage_len=random.randrange(4096)):
"""Initiator begins the v2 handshake by sending its ellswift bytes and garbage.
Here, the 64 bytes ellswift is assumed to have it's 4 bytes match network magic bytes. It is sent in 2 phases:
1. when `send_network_magic` = True, send first 4 bytes of ellswift (matches network magic bytes)
2. when `send_network_magic` = False, send remaining 60 bytes of ellswift
"""
if self.send_net_magic:
self.privkey_ours, self.ellswift_ours = ellswift_create()
self.sent_garbage = random.randbytes(garbage_len)
self.send_net_magic = False
return b"\xfa\xbf\xb5\xda"
else:
self.can_data_be_received = True
return self.ellswift_ours[4:] + self.sent_garbage


class PeerEarlyKey(P2PInterface):
"""Custom implementation of P2PInterface which uses modified v2 P2P protocol functions for testing purposes."""
def __init__(self):
super().__init__()
self.v2_state = None

def connection_made(self, transport):
"""64 bytes ellswift is sent in 2 parts during `initial_v2_handshake()`"""
self.v2_state = TestEncryptedP2PState()
super().connection_made(transport)

def data_received(self, t):
# check that data can be received on recvbuf only when mismatch from V1_PREFIX happens (send_net_magic = False)
assert self.v2_state.can_data_be_received and not self.v2_state.send_net_magic


class P2PEarlyKey(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 1
self.extra_args = [["-v2transport=1", "-peertimeout=3"]]

def run_test(self):
self.log.info('Sending ellswift bytes in parts to ensure that response from responder is received only when')
self.log.info('ellswift bytes have a mismatch from the 16 bytes(network magic followed by "version\\x00\\x00\\x00\\x00\\x00")')
node0 = self.nodes[0]
self.log.info('Sending first 4 bytes of ellswift which match network magic')
self.log.info('If a response is received, assertion failure would happen in our custom data_received() function')
# send happens in `initiate_v2_handshake()` in `connection_made()`
peer1 = node0.add_p2p_connection(PeerEarlyKey(), wait_for_verack=False, send_version=False, supports_v2_p2p=True)
self.log.info('Sending remaining ellswift and garbage which are different from V1_PREFIX. Since a response is')
self.log.info('expected now, our custom data_received() function wouldn\'t result in assertion failure')
ellswift_and_garbage_data = peer1.v2_state.initiate_v2_handshake()
peer1.send_raw_message(ellswift_and_garbage_data)
peer1.wait_for_disconnect(timeout=5)
self.log.info('successful disconnection when MITM happens in the key exchange phase')


if __name__ == '__main__':
P2PEarlyKey().main()
134 changes: 134 additions & 0 deletions test/functional/p2p_v2_encrypted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
# Copyright (c) 2022 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 encrypted v2 p2p proposed in BIP 324
"""
from test_framework.blocktools import (
create_block,
create_coinbase,
)
from test_framework.p2p import (
P2PDataStore,
P2PInterface,
)
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_greater_than,
check_node_connections,
)
from test_framework.crypto.chacha20 import REKEY_INTERVAL


class P2PEncrypted(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 2
self.extra_args = [["-v2transport=1"], ["-v2transport=1"]]

def setup_network(self):
self.setup_nodes()

def generate_blocks(self, node, number):
test_blocks = []
last_block = node.getbestblockhash()
tip = int(last_block, 16)
tipheight = node.getblockcount()
last_block_time = node.getblock(last_block)['time']
for _ in range(number):
# Create some blocks
block = create_block(tip, create_coinbase(tipheight + 1), last_block_time + 1)
block.solve()
test_blocks.append(block)
tip = block.sha256
tipheight += 1
last_block_time += 1
return test_blocks

def create_test_block(self, txs):
block = create_block(self.tip, create_coinbase(self.tipheight + 1), self.last_block_time + 600, txlist=txs)
block.solve()
return block

def run_test(self):
node0, node1 = self.nodes[0], self.nodes[1]
self.log.info("Check inbound connection to v2 TestNode from v2 P2PConnection is v2")
peer1 = node0.add_p2p_connection(P2PInterface(), wait_for_verack=True, supports_v2_p2p=True)
assert peer1.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v2")

self.log.info("Check inbound connection to v2 TestNode from v1 P2PConnection is v1")
peer2 = node0.add_p2p_connection(P2PInterface(), wait_for_verack=True, supports_v2_p2p=False)
assert not peer2.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v1")

self.log.info("Check outbound connection from v2 TestNode to v1 P2PConnection advertised as v1 is v1")
peer3 = node0.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, supports_v2_p2p=False, advertise_v2_p2p=False)
assert not peer3.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v1")

# v2 TestNode performs downgrading here
self.log.info("Check outbound connection from v2 TestNode to v1 P2PConnection advertised as v2 is v1")
peer4 = node0.add_outbound_p2p_connection(P2PInterface(), p2p_idx=1, supports_v2_p2p=False, advertise_v2_p2p=True)
assert not peer4.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v1")

self.log.info("Check outbound connection from v2 TestNode to v2 P2PConnection advertised as v2 is v2")
peer5 = node0.add_outbound_p2p_connection(P2PInterface(), p2p_idx=2, supports_v2_p2p=True, advertise_v2_p2p=True)
assert peer5.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v2")

self.log.info("Check if version is sent and verack is received in inbound/outbound connections")
assert_equal(len(node0.getpeerinfo()), 5) # check if above 5 connections are present in node0's getpeerinfo()
for peer in node0.getpeerinfo():
assert_greater_than(peer['bytessent_per_msg']['version'], 0)
assert_greater_than(peer['bytesrecv_per_msg']['verack'], 0)

self.log.info("Testing whether blocks propagate - check if tips sync when number of blocks >= REKEY_INTERVAL")
# tests whether rekeying (which happens every REKEY_INTERVAL packets) works correctly
test_blocks = self.generate_blocks(node0, REKEY_INTERVAL+1)

for i in range(2):
peer6 = node0.add_p2p_connection(P2PDataStore(), supports_v2_p2p=True)
assert peer6.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v2")

# Consider: node0 <-- peer6. node0 and node1 aren't connected here.
# Construct the following topology: node1 <--> node0 <-- peer6
# and test that blocks produced by peer6 will be received by node1 if sent normally
# and won't be received by node1 if sent as decoy messages

# First, check whether blocks produced be peer6 are received by node0 if sent normally
# and not received by node0 if sent as decoy messages.
if i:
# check that node0 receives blocks produced by peer6
self.log.info("Check if blocks produced by node0's p2p connection is received by node0")
peer6.send_blocks_and_test(test_blocks, node0, success=True) # node0's tip advances
else:
# check that node0 doesn't receive blocks produced by peer6 since they are sent as decoy messages
self.log.info("Check if blocks produced by node0's p2p connection sent as decoys aren't received by node0")
peer6.send_blocks_and_test(test_blocks, node0, success=False, is_decoy=True) # node0's tip doesn't advance

# Then, connect node0 and node1 using v2 and check whether the blocks are received by node1
self.connect_nodes(0, 1, peer_advertises_v2=True)
self.log.info("Wait for node1 to receive all the blocks from node0")
self.sync_all()
self.log.info("Make sure node0 and node1 have same block tips")
assert_equal(node0.getbestblockhash(), node1.getbestblockhash())

self.disconnect_nodes(0, 1)

self.log.info("Check the connections opened as expected")
check_node_connections(node=node0, num_in=4, num_out=3)

self.log.info("Check inbound connection to v1 TestNode from v2 P2PConnection is v1")
self.restart_node(0, ["-v2transport=0"])
peer1 = node0.add_p2p_connection(P2PInterface(), wait_for_verack=True, supports_v2_p2p=True)
assert not peer1.supports_v2_p2p
assert_equal(node0.getpeerinfo()[-1]["transport_protocol_type"], "v1")
check_node_connections(node=node0, num_in=1, num_out=0)


if __name__ == '__main__':
P2PEncrypted().main()
3 changes: 1 addition & 2 deletions test/functional/p2p_v2_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"""
import socket

from test_framework.messages import NODE_P2P_V2
from test_framework.p2p import MAGIC_BYTES
from test_framework.messages import MAGIC_BYTES, NODE_P2P_V2
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
Expand Down
5 changes: 4 additions & 1 deletion test/functional/rpc_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,10 @@ def test_addnode_getaddednodeinfo(self):
def test_service_flags(self):
self.log.info("Test service flags")
self.nodes[0].add_p2p_connection(P2PInterface(), services=(1 << 4) | (1 << 63))
assert_equal(['UNKNOWN[2^4]', 'UNKNOWN[2^63]'], self.nodes[0].getpeerinfo()[-1]['servicesnames'])
if self.options.v2transport:
assert_equal(['UNKNOWN[2^4]', 'P2P_V2', 'UNKNOWN[2^63]'], self.nodes[0].getpeerinfo()[-1]['servicesnames'])
else:
assert_equal(['UNKNOWN[2^4]', 'UNKNOWN[2^63]'], self.nodes[0].getpeerinfo()[-1]['servicesnames'])
self.nodes[0].disconnect_p2ps()

def test_getnodeaddresses(self):
Expand Down
7 changes: 7 additions & 0 deletions test/functional/test_framework/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@

DEFAULT_MEMPOOL_EXPIRY_HOURS = 336 # hours

MAGIC_BYTES = {
"mainnet": b"\xf9\xbe\xb4\xd9", # mainnet
"testnet3": b"\x0b\x11\x09\x07", # testnet3
"regtest": b"\xfa\xbf\xb5\xda", # regtest
"signet": b"\x0a\x03\xcf\x40", # signet
}

def sha256(s):
return hashlib.sha256(s).digest()

Expand Down
Loading
Loading