|
4 | 4 | # file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
5 | 5 | """Class for v2 P2P protocol (see BIP 324)"""
|
6 | 6 |
|
7 |
| -from .crypto.ellswift import ellswift_ecdh_xonly |
| 7 | +import logging |
| 8 | +import random |
| 9 | + |
| 10 | +from .crypto.bip324_cipher import FSChaCha20Poly1305 |
| 11 | +from .crypto.chacha20 import FSChaCha20 |
| 12 | +from .crypto.ellswift import ellswift_create, ellswift_ecdh_xonly |
| 13 | +from .crypto.hkdf import hkdf_sha256 |
8 | 14 | from .key import TaggedHash
|
| 15 | +from .messages import MAGIC_BYTES |
| 16 | + |
| 17 | +logger = logging.getLogger("TestFramework.v2_p2p") |
| 18 | + |
| 19 | +CHACHA20POLY1305_EXPANSION = 16 |
| 20 | +HEADER_LEN = 1 |
| 21 | +IGNORE_BIT_POS = 7 |
| 22 | +LENGTH_FIELD_LEN = 3 |
| 23 | +MAX_GARBAGE_LEN = 4095 |
| 24 | +TRANSPORT_VERSION = b'' |
| 25 | + |
| 26 | +SHORTID = { |
| 27 | + 1: b"addr", |
| 28 | + 2: b"block", |
| 29 | + 3: b"blocktxn", |
| 30 | + 4: b"cmpctblock", |
| 31 | + 5: b"feefilter", |
| 32 | + 6: b"filteradd", |
| 33 | + 7: b"filterclear", |
| 34 | + 8: b"filterload", |
| 35 | + 9: b"getblocks", |
| 36 | + 10: b"getblocktxn", |
| 37 | + 11: b"getdata", |
| 38 | + 12: b"getheaders", |
| 39 | + 13: b"headers", |
| 40 | + 14: b"inv", |
| 41 | + 15: b"mempool", |
| 42 | + 16: b"merkleblock", |
| 43 | + 17: b"notfound", |
| 44 | + 18: b"ping", |
| 45 | + 19: b"pong", |
| 46 | + 20: b"sendcmpct", |
| 47 | + 21: b"tx", |
| 48 | + 22: b"getcfilters", |
| 49 | + 23: b"cfilter", |
| 50 | + 24: b"getcfheaders", |
| 51 | + 25: b"cfheaders", |
| 52 | + 26: b"getcfcheckpt", |
| 53 | + 27: b"cfcheckpt", |
| 54 | + 28: b"addrv2", |
| 55 | +} |
| 56 | + |
| 57 | +# Dictionary which contains short message type ID for the P2P message |
| 58 | +MSGTYPE_TO_SHORTID = {msgtype: shortid for shortid, msgtype in SHORTID.items()} |
| 59 | + |
9 | 60 |
|
10 | 61 | class EncryptedP2PState:
|
| 62 | + """A class for managing the state when v2 P2P protocol is used. Performs initial v2 handshake and encrypts/decrypts |
| 63 | + P2P messages. P2PConnection uses an object of this class. |
| 64 | +
|
| 65 | +
|
| 66 | + Args: |
| 67 | + initiating (bool): defines whether the P2PConnection is an initiator or responder. |
| 68 | + - initiating = True for inbound connections in the test framework [TestNode <------- P2PConnection] |
| 69 | + - initiating = False for outbound connections in the test framework [TestNode -------> P2PConnection] |
| 70 | +
|
| 71 | + net (string): chain used (regtest, signet etc..) |
| 72 | +
|
| 73 | + Methods: |
| 74 | + perform an advanced form of diffie-hellman handshake to instantiate the encrypted transport. before exchanging |
| 75 | + any P2P messages, 2 nodes perform this handshake in order to determine a shared secret that is unique to both |
| 76 | + of them and use it to derive keys to encrypt/decrypt P2P messages. |
| 77 | + - initial v2 handshakes is performed by: (see BIP324 section #overall-handshake-pseudocode) |
| 78 | + 1. initiator using initiate_v2_handshake(), complete_handshake() and authenticate_handshake() |
| 79 | + 2. responder using respond_v2_handshake(), complete_handshake() and authenticate_handshake() |
| 80 | + - initialize_v2_transport() sets various BIP324 derived keys and ciphers. |
| 81 | +
|
| 82 | + encrypt/decrypt v2 P2P messages using v2_enc_packet() and v2_receive_packet(). |
| 83 | + """ |
| 84 | + def __init__(self, *, initiating, net): |
| 85 | + self.initiating = initiating # True if initiator |
| 86 | + self.net = net |
| 87 | + self.peer = {} # object with various BIP324 derived keys and ciphers |
| 88 | + self.privkey_ours = None |
| 89 | + self.ellswift_ours = None |
| 90 | + self.sent_garbage = b"" |
| 91 | + self.received_garbage = b"" |
| 92 | + self.received_prefix = b"" # received ellswift bytes till the first mismatch from 16 bytes v1_prefix |
| 93 | + self.tried_v2_handshake = False # True when the initial handshake is over |
| 94 | + # stores length of packet contents to detect whether first 3 bytes (which contains length of packet contents) |
| 95 | + # has been decrypted. set to -1 if decryption hasn't been done yet. |
| 96 | + self.contents_len = -1 |
| 97 | + self.found_garbage_terminator = False |
| 98 | + |
11 | 99 | @staticmethod
|
12 | 100 | def v2_ecdh(priv, ellswift_theirs, ellswift_ours, initiating):
|
13 |
| - """Compute BIP324 shared secret.""" |
| 101 | + """Compute BIP324 shared secret. |
| 102 | +
|
| 103 | + Returns: |
| 104 | + bytes - BIP324 shared secret |
| 105 | + """ |
14 | 106 | ecdh_point_x32 = ellswift_ecdh_xonly(ellswift_theirs, priv)
|
15 | 107 | if initiating:
|
16 | 108 | # Initiating, place our public key encoding first.
|
17 | 109 | return TaggedHash("bip324_ellswift_xonly_ecdh", ellswift_ours + ellswift_theirs + ecdh_point_x32)
|
18 | 110 | else:
|
19 | 111 | # Responding, place their public key encoding first.
|
20 | 112 | return TaggedHash("bip324_ellswift_xonly_ecdh", ellswift_theirs + ellswift_ours + ecdh_point_x32)
|
| 113 | + |
| 114 | + def generate_keypair_and_garbage(self): |
| 115 | + """Generates ellswift keypair and 4095 bytes garbage at max""" |
| 116 | + self.privkey_ours, self.ellswift_ours = ellswift_create() |
| 117 | + garbage_len = random.randrange(MAX_GARBAGE_LEN + 1) |
| 118 | + self.sent_garbage = random.randbytes(garbage_len) |
| 119 | + logger.debug(f"sending {garbage_len} bytes of garbage data") |
| 120 | + return self.ellswift_ours + self.sent_garbage |
| 121 | + |
| 122 | + def initiate_v2_handshake(self): |
| 123 | + """Initiator begins the v2 handshake by sending its ellswift bytes and garbage |
| 124 | +
|
| 125 | + Returns: |
| 126 | + bytes - bytes to be sent to the peer when starting the v2 handshake as an initiator |
| 127 | + """ |
| 128 | + return self.generate_keypair_and_garbage() |
| 129 | + |
| 130 | + def respond_v2_handshake(self, response): |
| 131 | + """Responder begins the v2 handshake by sending its ellswift bytes and garbage. However, the responder |
| 132 | + sends this after having received at least one byte that mismatches 16-byte v1_prefix. |
| 133 | +
|
| 134 | + Returns: |
| 135 | + 1. int - length of bytes that were consumed so that recvbuf can be updated |
| 136 | + 2. bytes - bytes to be sent to the peer when starting the v2 handshake as a responder. |
| 137 | + - returns b"" if more bytes need to be received before we can respond and start the v2 handshake. |
| 138 | + - returns -1 to downgrade the connection to v1 P2P. |
| 139 | + """ |
| 140 | + v1_prefix = MAGIC_BYTES[self.net] + b'version\x00\x00\x00\x00\x00' |
| 141 | + while len(self.received_prefix) < 16: |
| 142 | + byte = response.read(1) |
| 143 | + # return b"" if we need to receive more bytes |
| 144 | + if not byte: |
| 145 | + return len(self.received_prefix), b"" |
| 146 | + self.received_prefix += byte |
| 147 | + if self.received_prefix[-1] != v1_prefix[len(self.received_prefix) - 1]: |
| 148 | + return len(self.received_prefix), self.generate_keypair_and_garbage() |
| 149 | + # return -1 to decide v1 only after all 16 bytes processed |
| 150 | + return len(self.received_prefix), -1 |
| 151 | + |
| 152 | + def complete_handshake(self, response): |
| 153 | + """ Instantiates the encrypted transport and |
| 154 | + sends garbage terminator + optional decoy packets + transport version packet. |
| 155 | + Done by both initiator and responder. |
| 156 | +
|
| 157 | + Returns: |
| 158 | + 1. int - length of bytes that were consumed. returns 0 if all 64 bytes from ellswift haven't been received yet. |
| 159 | + 2. bytes - bytes to be sent to the peer when completing the v2 handshake |
| 160 | + """ |
| 161 | + ellswift_theirs = self.received_prefix + response.read(64 - len(self.received_prefix)) |
| 162 | + # return b"" if we need to receive more bytes |
| 163 | + if len(ellswift_theirs) != 64: |
| 164 | + return 0, b"" |
| 165 | + ecdh_secret = self.v2_ecdh(self.privkey_ours, ellswift_theirs, self.ellswift_ours, self.initiating) |
| 166 | + self.initialize_v2_transport(ecdh_secret) |
| 167 | + # Send garbage terminator |
| 168 | + msg_to_send = self.peer['send_garbage_terminator'] |
| 169 | + # Optionally send decoy packets after garbage terminator. |
| 170 | + aad = self.sent_garbage |
| 171 | + for decoy_content_len in [random.randint(1, 100) for _ in range(random.randint(0, 10))]: |
| 172 | + msg_to_send += self.v2_enc_packet(decoy_content_len * b'\x00', aad=aad, ignore=True) |
| 173 | + aad = b'' |
| 174 | + # Send version packet. |
| 175 | + msg_to_send += self.v2_enc_packet(TRANSPORT_VERSION, aad=aad) |
| 176 | + return 64 - len(self.received_prefix), msg_to_send |
| 177 | + |
| 178 | + def authenticate_handshake(self, response): |
| 179 | + """ Ensures that the received optional decoy packets and transport version packet are authenticated. |
| 180 | + Marks the v2 handshake as complete. Done by both initiator and responder. |
| 181 | +
|
| 182 | + Returns: |
| 183 | + 1. int - length of bytes that were processed so that recvbuf can be updated |
| 184 | + 2. bool - True if the authentication was successful/more bytes need to be received and False otherwise |
| 185 | + """ |
| 186 | + processed_length = 0 |
| 187 | + |
| 188 | + # Detect garbage terminator in the received bytes |
| 189 | + if not self.found_garbage_terminator: |
| 190 | + received_garbage = response[:16] |
| 191 | + response = response[16:] |
| 192 | + processed_length = len(received_garbage) |
| 193 | + for i in range(MAX_GARBAGE_LEN + 1): |
| 194 | + if received_garbage[-16:] == self.peer['recv_garbage_terminator']: |
| 195 | + # Receive, decode, and ignore version packet. |
| 196 | + # This includes skipping decoys and authenticating the received garbage. |
| 197 | + self.found_garbage_terminator = True |
| 198 | + self.received_garbage = received_garbage[:-16] |
| 199 | + break |
| 200 | + else: |
| 201 | + # don't update recvbuf since more bytes need to be received |
| 202 | + if len(response) == 0: |
| 203 | + return 0, True |
| 204 | + received_garbage += response[:1] |
| 205 | + processed_length += 1 |
| 206 | + response = response[1:] |
| 207 | + else: |
| 208 | + # disconnect since garbage terminator was not seen after 4 KiB of garbage. |
| 209 | + return processed_length, False |
| 210 | + |
| 211 | + # Process optional decoy packets and transport version packet |
| 212 | + while not self.tried_v2_handshake: |
| 213 | + length, contents = self.v2_receive_packet(response, aad=self.received_garbage) |
| 214 | + if length == -1: |
| 215 | + return processed_length, False |
| 216 | + elif length == 0: |
| 217 | + return processed_length, True |
| 218 | + processed_length += length |
| 219 | + self.received_garbage = b"" |
| 220 | + # decoy packets have contents = None. v2 handshake is complete only when version packet |
| 221 | + # (can be empty with contents = b"") with contents != None is received. |
| 222 | + if contents is not None: |
| 223 | + self.tried_v2_handshake = True |
| 224 | + return processed_length, True |
| 225 | + response = response[length:] |
| 226 | + |
| 227 | + def initialize_v2_transport(self, ecdh_secret): |
| 228 | + """Sets the peer object with various BIP324 derived keys and ciphers.""" |
| 229 | + peer = {} |
| 230 | + salt = b'bitcoin_v2_shared_secret' + MAGIC_BYTES[self.net] |
| 231 | + for name in ('initiator_L', 'initiator_P', 'responder_L', 'responder_P', 'garbage_terminators', 'session_id'): |
| 232 | + peer[name] = hkdf_sha256(salt=salt, ikm=ecdh_secret, info=name.encode('utf-8'), length=32) |
| 233 | + if self.initiating: |
| 234 | + self.peer['send_L'] = FSChaCha20(peer['initiator_L']) |
| 235 | + self.peer['send_P'] = FSChaCha20Poly1305(peer['initiator_P']) |
| 236 | + self.peer['send_garbage_terminator'] = peer['garbage_terminators'][:16] |
| 237 | + self.peer['recv_L'] = FSChaCha20(peer['responder_L']) |
| 238 | + self.peer['recv_P'] = FSChaCha20Poly1305(peer['responder_P']) |
| 239 | + self.peer['recv_garbage_terminator'] = peer['garbage_terminators'][16:] |
| 240 | + else: |
| 241 | + self.peer['send_L'] = FSChaCha20(peer['responder_L']) |
| 242 | + self.peer['send_P'] = FSChaCha20Poly1305(peer['responder_P']) |
| 243 | + self.peer['send_garbage_terminator'] = peer['garbage_terminators'][16:] |
| 244 | + self.peer['recv_L'] = FSChaCha20(peer['initiator_L']) |
| 245 | + self.peer['recv_P'] = FSChaCha20Poly1305(peer['initiator_P']) |
| 246 | + self.peer['recv_garbage_terminator'] = peer['garbage_terminators'][:16] |
| 247 | + self.peer['session_id'] = peer['session_id'] |
| 248 | + |
| 249 | + def v2_enc_packet(self, contents, aad=b'', ignore=False): |
| 250 | + """Encrypt a BIP324 packet. |
| 251 | +
|
| 252 | + Returns: |
| 253 | + bytes - encrypted packet contents |
| 254 | + """ |
| 255 | + assert len(contents) <= 2**24 - 1 |
| 256 | + header = (ignore << IGNORE_BIT_POS).to_bytes(HEADER_LEN, 'little') |
| 257 | + plaintext = header + contents |
| 258 | + aead_ciphertext = self.peer['send_P'].encrypt(aad, plaintext) |
| 259 | + enc_plaintext_len = self.peer['send_L'].crypt(len(contents).to_bytes(LENGTH_FIELD_LEN, 'little')) |
| 260 | + return enc_plaintext_len + aead_ciphertext |
| 261 | + |
| 262 | + def v2_receive_packet(self, response, aad=b''): |
| 263 | + """Decrypt a BIP324 packet |
| 264 | +
|
| 265 | + Returns: |
| 266 | + 1. int - number of bytes consumed (or -1 if error) |
| 267 | + 2. bytes - contents of decrypted non-decoy packet if any (or None otherwise) |
| 268 | + """ |
| 269 | + if self.contents_len == -1: |
| 270 | + if len(response) < LENGTH_FIELD_LEN: |
| 271 | + return 0, None |
| 272 | + enc_contents_len = response[:LENGTH_FIELD_LEN] |
| 273 | + self.contents_len = int.from_bytes(self.peer['recv_L'].crypt(enc_contents_len), 'little') |
| 274 | + response = response[LENGTH_FIELD_LEN:] |
| 275 | + if len(response) < HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION: |
| 276 | + return 0, None |
| 277 | + aead_ciphertext = response[:HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION] |
| 278 | + plaintext = self.peer['recv_P'].decrypt(aad, aead_ciphertext) |
| 279 | + if plaintext is None: |
| 280 | + return -1, None # disconnect |
| 281 | + header = plaintext[:HEADER_LEN] |
| 282 | + length = LENGTH_FIELD_LEN + HEADER_LEN + self.contents_len + CHACHA20POLY1305_EXPANSION |
| 283 | + self.contents_len = -1 |
| 284 | + return length, None if (header[0] & (1 << IGNORE_BIT_POS)) else plaintext[HEADER_LEN:] |
0 commit comments