mirror of https://github.com/bitcoin/bitcoin
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
286 lines
13 KiB
286 lines
13 KiB
#!/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.
|
|
"""Class for v2 P2P protocol (see BIP 324)"""
|
|
|
|
import logging
|
|
import random
|
|
|
|
from .crypto.bip324_cipher import FSChaCha20Poly1305
|
|
from .crypto.chacha20 import FSChaCha20
|
|
from .crypto.ellswift import ellswift_create, ellswift_ecdh_xonly
|
|
from .crypto.hkdf import hkdf_sha256
|
|
from .key import TaggedHash
|
|
from .messages import MAGIC_BYTES
|
|
|
|
logger = logging.getLogger("TestFramework.v2_p2p")
|
|
|
|
CHACHA20POLY1305_EXPANSION = 16
|
|
HEADER_LEN = 1
|
|
IGNORE_BIT_POS = 7
|
|
LENGTH_FIELD_LEN = 3
|
|
MAX_GARBAGE_LEN = 4095
|
|
TRANSPORT_VERSION = b''
|
|
|
|
SHORTID = {
|
|
1: b"addr",
|
|
2: b"block",
|
|
3: b"blocktxn",
|
|
4: b"cmpctblock",
|
|
5: b"feefilter",
|
|
6: b"filteradd",
|
|
7: b"filterclear",
|
|
8: b"filterload",
|
|
9: b"getblocks",
|
|
10: b"getblocktxn",
|
|
11: b"getdata",
|
|
12: b"getheaders",
|
|
13: b"headers",
|
|
14: b"inv",
|
|
15: b"mempool",
|
|
16: b"merkleblock",
|
|
17: b"notfound",
|
|
18: b"ping",
|
|
19: b"pong",
|
|
20: b"sendcmpct",
|
|
21: b"tx",
|
|
22: b"getcfilters",
|
|
23: b"cfilter",
|
|
24: b"getcfheaders",
|
|
25: b"cfheaders",
|
|
26: b"getcfcheckpt",
|
|
27: b"cfcheckpt",
|
|
28: b"addrv2",
|
|
}
|
|
|
|
# Dictionary which contains short message type ID for the P2P message
|
|
MSGTYPE_TO_SHORTID = {msgtype: shortid for shortid, msgtype in SHORTID.items()}
|
|
|
|
|
|
class EncryptedP2PState:
|
|
"""A class for managing the state when v2 P2P protocol is used. Performs initial v2 handshake and encrypts/decrypts
|
|
P2P messages. P2PConnection uses an object of this class.
|
|
|
|
|
|
Args:
|
|
initiating (bool): defines whether the P2PConnection is an initiator or responder.
|
|
- initiating = True for inbound connections in the test framework [TestNode <------- P2PConnection]
|
|
- initiating = False for outbound connections in the test framework [TestNode -------> P2PConnection]
|
|
|
|
net (string): chain used (regtest, signet etc..)
|
|
|
|
Methods:
|
|
perform an advanced form of diffie-hellman handshake to instantiate the encrypted transport. before exchanging
|
|
any P2P messages, 2 nodes perform this handshake in order to determine a shared secret that is unique to both
|
|
of them and use it to derive keys to encrypt/decrypt P2P messages.
|
|
- initial v2 handshakes is performed by: (see BIP324 section #overall-handshake-pseudocode)
|
|
1. initiator using initiate_v2_handshake(), complete_handshake() and authenticate_handshake()
|
|
2. responder using respond_v2_handshake(), complete_handshake() and authenticate_handshake()
|
|
- initialize_v2_transport() sets various BIP324 derived keys and ciphers.
|
|
|
|
encrypt/decrypt v2 P2P messages using v2_enc_packet() and v2_receive_packet().
|
|
"""
|
|
def __init__(self, *, initiating, net):
|
|
self.initiating = initiating # True if initiator
|
|
self.net = net
|
|
self.peer = {} # object with various BIP324 derived keys and ciphers
|
|
self.privkey_ours = None
|
|
self.ellswift_ours = None
|
|
self.sent_garbage = b""
|
|
self.received_garbage = b""
|
|
self.received_prefix = b"" # received ellswift bytes till the first mismatch from 16 bytes v1_prefix
|
|
self.tried_v2_handshake = False # True when the initial handshake is over
|
|
# stores length of packet contents to detect whether first 3 bytes (which contains length of packet contents)
|
|
# has been decrypted. set to -1 if decryption hasn't been done yet.
|
|
self.contents_len = -1
|
|
self.found_garbage_terminator = False
|
|
|
|
@staticmethod
|
|
def v2_ecdh(priv, ellswift_theirs, ellswift_ours, initiating):
|
|
"""Compute BIP324 shared secret.
|
|
|
|
Returns:
|
|
bytes - BIP324 shared secret
|
|
"""
|
|
ecdh_point_x32 = ellswift_ecdh_xonly(ellswift_theirs, priv)
|
|
if initiating:
|
|
# Initiating, place our public key encoding first.
|
|
return TaggedHash("bip324_ellswift_xonly_ecdh", ellswift_ours + ellswift_theirs + ecdh_point_x32)
|
|
else:
|
|
# Responding, place their public key encoding first.
|
|
return TaggedHash("bip324_ellswift_xonly_ecdh", ellswift_theirs + ellswift_ours + ecdh_point_x32)
|
|
|
|
def generate_keypair_and_garbage(self):
|
|
"""Generates ellswift keypair and 4095 bytes garbage at max"""
|
|
self.privkey_ours, self.ellswift_ours = ellswift_create()
|
|
garbage_len = random.randrange(MAX_GARBAGE_LEN + 1)
|
|
self.sent_garbage = random.randbytes(garbage_len)
|
|
logger.debug(f"sending {garbage_len} bytes of garbage data")
|
|
return self.ellswift_ours + self.sent_garbage
|
|
|
|
def initiate_v2_handshake(self):
|
|
"""Initiator begins the v2 handshake by sending its ellswift bytes and garbage
|
|
|
|
Returns:
|
|
bytes - bytes to be sent to the peer when starting the v2 handshake as an initiator
|
|
"""
|
|
return self.generate_keypair_and_garbage()
|
|
|
|
def respond_v2_handshake(self, response):
|
|
"""Responder begins the v2 handshake by sending its ellswift bytes and garbage. However, the responder
|
|
sends this after having received at least one byte that mismatches 16-byte v1_prefix.
|
|
|
|
Returns:
|
|
1. int - length of bytes that were consumed so that recvbuf can be updated
|
|
2. bytes - bytes to be sent to the peer when starting the v2 handshake as a responder.
|
|
- returns b"" if more bytes need to be received before we can respond and start the v2 handshake.
|
|
- returns -1 to downgrade the connection to v1 P2P.
|
|
"""
|
|
v1_prefix = MAGIC_BYTES[self.net] + b'version\x00\x00\x00\x00\x00'
|
|
while len(self.received_prefix) < 16:
|
|
byte = response.read(1)
|
|
# return b"" if we need to receive more bytes
|
|
if not byte:
|
|
return len(self.received_prefix), b""
|
|
self.received_prefix += byte
|
|
if self.received_prefix[-1] != v1_prefix[len(self.received_prefix) - 1]:
|
|
return len(self.received_prefix), self.generate_keypair_and_garbage()
|
|
# return -1 to decide v1 only after all 16 bytes processed
|
|
return len(self.received_prefix), -1
|
|
|
|
def complete_handshake(self, response):
|
|
""" Instantiates the encrypted transport and
|
|
sends garbage terminator + optional decoy packets + transport version packet.
|
|
Done by both initiator and responder.
|
|
|
|
Returns:
|
|
1. int - length of bytes that were consumed. returns 0 if all 64 bytes from ellswift haven't been received yet.
|
|
2. bytes - bytes to be sent to the peer when completing the v2 handshake
|
|
"""
|
|
ellswift_theirs = self.received_prefix + response.read(64 - len(self.received_prefix))
|
|
# return b"" if we need to receive more bytes
|
|
if len(ellswift_theirs) != 64:
|
|
return 0, b""
|
|
ecdh_secret = self.v2_ecdh(self.privkey_ours, ellswift_theirs, self.ellswift_ours, self.initiating)
|
|
self.initialize_v2_transport(ecdh_secret)
|
|
# Send garbage terminator
|
|
msg_to_send = self.peer['send_garbage_terminator']
|
|
# Optionally send decoy packets after garbage terminator.
|
|
aad = self.sent_garbage
|
|
for decoy_content_len in [random.randint(1, 100) for _ in range(random.randint(0, 10))]:
|
|
msg_to_send += self.v2_enc_packet(decoy_content_len * b'\x00', aad=aad, ignore=True)
|
|
aad = b''
|
|
# Send version packet.
|
|
msg_to_send += self.v2_enc_packet(TRANSPORT_VERSION, aad=aad)
|
|
return 64 - len(self.received_prefix), msg_to_send
|
|
|
|
def authenticate_handshake(self, response):
|
|
""" Ensures that the received optional decoy packets and transport version packet are authenticated.
|
|
Marks the v2 handshake as complete. Done by both initiator and responder.
|
|
|
|
Returns:
|
|
1. int - length of bytes that were processed so that recvbuf can be updated
|
|
2. bool - True if the authentication was successful/more bytes need to be received and False otherwise
|
|
"""
|
|
processed_length = 0
|
|
|
|
# Detect garbage terminator in the received bytes
|
|
if not self.found_garbage_terminator:
|
|
received_garbage = response[:16]
|
|
response = response[16:]
|
|
processed_length = len(received_garbage)
|
|
for i in range(MAX_GARBAGE_LEN + 1):
|
|
if received_garbage[-16:] == self.peer['recv_garbage_terminator']:
|
|
# Receive, decode, and ignore version packet.
|
|
# This includes skipping decoys and authenticating the received garbage.
|
|
self.found_garbage_terminator = True
|
|
self.received_garbage = received_garbage[:-16]
|
|
break
|
|
else:
|
|
# don't update recvbuf since more bytes need to be received
|
|
if len(response) == 0:
|
|
return 0, True
|
|
received_garbage += response[:1]
|
|
processed_length += 1
|
|
response = response[1:]
|
|
else:
|
|
# disconnect since garbage terminator was not seen after 4 KiB of garbage.
|
|
return processed_length, False
|
|
|
|
# Process optional decoy packets and transport version packet
|
|
while not self.tried_v2_handshake:
|
|
length, contents = self.v2_receive_packet(response, aad=self.received_garbage)
|
|
if length == -1:
|
|
return processed_length, False
|
|
elif length == 0:
|
|
return processed_length, True
|
|
processed_length += length
|
|
self.received_garbage = b""
|
|
# decoy packets have contents = None. v2 handshake is complete only when version packet
|
|
# (can be empty with contents = b"") with contents != None is received.
|
|
if contents is not None:
|
|
assert contents == b"" # currently TestNode sends an empty version packet
|
|
self.tried_v2_handshake = True
|
|
return processed_length, True
|
|
response = response[length:]
|
|
|
|
def initialize_v2_transport(self, ecdh_secret):
|
|
"""Sets the peer object with various BIP324 derived keys and ciphers."""
|
|
peer = {}
|
|
salt = b'bitcoin_v2_shared_secret' + MAGIC_BYTES[self.net]
|
|
for name in ('initiator_L', 'initiator_P', 'responder_L', 'responder_P', 'garbage_terminators', 'session_id'):
|
|
peer[name] = hkdf_sha256(salt=salt, ikm=ecdh_secret, info=name.encode('utf-8'), length=32)
|
|
if self.initiating:
|
|
self.peer['send_L'] = FSChaCha20(peer['initiator_L'])
|
|
self.peer['send_P'] = FSChaCha20Poly1305(peer['initiator_P'])
|
|
self.peer['send_garbage_terminator'] = peer['garbage_terminators'][:16]
|
|
self.peer['recv_L'] = FSChaCha20(peer['responder_L'])
|
|
self.peer['recv_P'] = FSChaCha20Poly1305(peer['responder_P'])
|
|
self.peer['recv_garbage_terminator'] = peer['garbage_terminators'][16:]
|
|
else:
|
|
self.peer['send_L'] = FSChaCha20(peer['responder_L'])
|
|
self.peer['send_P'] = FSChaCha20Poly1305(peer['responder_P'])
|
|
self.peer['send_garbage_terminator'] = peer['garbage_terminators'][16:]
|
|
self.peer['recv_L'] = FSChaCha20(peer['initiator_L'])
|
|
self.peer['recv_P'] = FSChaCha20Poly1305(peer['initiator_P'])
|
|
self.peer['recv_garbage_terminator'] = peer['garbage_terminators'][:16]
|
|
self.peer['session_id'] = peer['session_id']
|
|
|
|
def v2_enc_packet(self, contents, aad=b'', ignore=False):
|
|
"""Encrypt a BIP324 packet.
|
|
|
|
Returns:
|
|
bytes - encrypted packet contents
|
|
"""
|
|
assert len(contents) <= 2**24 - 1
|
|
header = (ignore << IGNORE_BIT_POS).to_bytes(HEADER_LEN, 'little')
|
|
plaintext = header + contents
|
|
aead_ciphertext = self.peer['send_P'].encrypt(aad, plaintext)
|
|
enc_plaintext_len = self.peer['send_L'].crypt(len(contents).to_bytes(LENGTH_FIELD_LEN, 'little'))
|
|
return enc_plaintext_len + aead_ciphertext
|
|
|
|
def v2_receive_packet(self, response, aad=b''):
|
|
"""Decrypt a BIP324 packet
|
|
|
|
Returns:
|
|
1. int - number of bytes consumed (or -1 if error)
|
|
2. bytes - contents of decrypted non-decoy packet if any (or None otherwise)
|
|
"""
|
|
if self.contents_len == -1:
|
|
if len(response) < LENGTH_FIELD_LEN:
|
|
return 0, None
|
|
enc_contents_len = response[:LENGTH_FIELD_LEN]
|
|
self.contents_len = int.from_bytes(self.peer['recv_L'].crypt(enc_contents_len), 'little')
|
|
response = response[LENGTH_FIELD_LEN:]
|
|
if len(response) < HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION:
|
|
return 0, None
|
|
aead_ciphertext = response[:HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION]
|
|
plaintext = self.peer['recv_P'].decrypt(aad, aead_ciphertext)
|
|
if plaintext is None:
|
|
return -1, None # disconnect
|
|
header = plaintext[:HEADER_LEN]
|
|
length = LENGTH_FIELD_LEN + HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION
|
|
self.contents_len = -1
|
|
return length, None if (header[0] & (1 << IGNORE_BIT_POS)) else plaintext[HEADER_LEN:]
|