v2_p2p.py raw

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