관리 메뉴

HAMA 블로그

[이더리움 코어] DevP2P 소스코드 분석 (feat. Python) 본문

블록체인

[이더리움 코어] DevP2P 소스코드 분석 (feat. Python)

[하마] 이승현 (wowlsh93@gmail.com) 2018. 6. 27. 18:13

서론

이 글에서는 이더리움 코어의 중요 축인 P2P에 관해서 분석해 보도록 하는데, 관련 소스는 파이썬 구현체인 pydevp2p 를 대상으로 한다. go 구현체도 있는데 왜 파이썬이냐?  

첫째. pydevp2p 는 p2p 에 관해서 독립적인 모듈이다. 즉 이더리움 뿐만 아니라, 분산p2p네트워킹을 하려는 많은 곳에서 재사용 될 수 있다. devp2p와 비슷한 libp2p 는 IPFS에 사용되었으며, 이더리움 SWARM 프로토콜을 이해하는데도 필수적이다.
둘째. 읽기 쉽다. 파이썬은 최고로 가독성이 좋은 언어이다. 개인적으로 모든 개발자들은 커뮤니케이션을 위해 파이썬을 읽을 줄 알아야한다고 생각한다. 

사전지식

소스를 온전히 이해하기 위한 많은 사전 지식이 있는데 나열해 보면 아래와 같다.

1. 파이썬 언어 (기본 문법 + 리플렉션 등 고급문법)
2. gevent 라이브러리를 이용한 소켓통신 및 비동기 I/O , Multiplexing  개념
3. ECC 기술들(ECDSA,ECDH),대칭키,공개키,암호화해싱,서명 같은 암호화 기본 
4. Kademlia DHT 
5. RLP 인코딩/디코딩
6. 기타 (NAT,홀펀칭,UPNP 등 개념)

pydevp2p 소개

pydevp2p 홈페이지에 있는 내용은 대략 이렇다.

"pydevp2p is the Python implementation of the RLPx network layer. RLPx provides a general-purpose transport and interface for applications to communicate via a p2p network. The first version is geared towards building a robust transport, well-formed network, and software interface in order to provide infrastructure which meets the requirements of distributed or decentralized applications such as Ethereum. Encryption is employed to provide better privacy and integrity than would be provided by a cleartext implementation."

pydevp2p 가 제공하는 기능들은 아래와 같은데 소스에서 어떻게 구현 되어 있는지  몇 가지를 살펴 볼 것이다. 사실 개발자라면 이런 추상(?)틱한 소개글 보다는 소스를 직접보는게 훨씬 이해하기 쉬울 것이다. 

Features

  • Node Discovery and Network Formation
  • Peer Preference Strategies
  • Peer Reputation
  • Multiple protocols
  • Encrypted handshake
  • Encrypted transport
  • Dynamically framed transport
  • Fair queuing

Security Overview

  • nodes have access to a uniform network topology
  • peers can uniformly connect to network
  • network robustness >= kademlia
  • protocols sharing a connection are provided uniform bandwidth
  • authenticated connectivity
  • authenticated discovery protocol
  • encrypted transport (TCP now; UDP in future)
  • robust node discovery


소스 분석 시작 

1. app (app.py)

pydevp2p 는 아래와 같은 2가지 서비스로 이루어져 있다.

프로그램의 시작점인 app.py 를 보면 아래와 같이 두개의 서비스를 등록&시작하는 코드를 볼 수 있다. 

# register services
NodeDiscovery.register_with_app(app)
PeerManager.register_with_app(app)

각각의 서비스를 등록하고 

def start(self):
for service in self.services.values():
service.start()

각각의 서비스를 시작한다. 이들 서비스는 BaseService 라는 공통부모를 가지고 있는데, Greenlet 이기도 하다.

class BaseService(Greenlet):

Greenlet 은 gevent 라이브러리에서 지원하는 경량 쓰레드라고 생각하면 된다. (golang의 코루틴과 비슷한 면이 있다) 즉 서비스들은 별개의 쓰레드로 작동한다고 보면 된다. 

2. NodeDiscovery 

노드디스커버리는 처음 어플리케이션이 실행 될 때, 주변의 연결될 노드들의 정보를 찾고, 다른 노드들을 위해 (다른노드들의 노드검색을 위해) 자신과 가까운 노드들의 정보를 저장해 놓는 곳에 대한 알고리즘을 다룬다. kademlia DHT 를 알고 있다고 가정한다. 모르면 예전에 썼던 글을 참고하라. [Ethereum] Node Discovery with Kademlia 

클래스 구성은 대략 위와 같으며 대락 설명하면 

1. NodeDiscovery 는 pydevp2p 에서 제공하는 서비스의 일종이다. 
2. NodeDiscovery 서비스는 노드를 찾기위한 DiscoveryProtocol 를 사용하는데, 구체적으로 KademliaProtocol 를 사용한다.
3. NodeDiscovery 서비스는 DiscoveryProtocol 를 통해 발생하는 명령들을 최종적으로 UDP 를 이용하여 전송하고 받는다. 
4. DiscoveryProtocol 는 하위 프로토콜이 무엇이든지 간에 (여기서는 Kademlia 지만) RLP 를 통해 전송/수신 메세지에 대해 인코딩/디코딩하고, 암호화 한다. 
5. 모든 노드디스커버리 프로토콜이 상속받아야하는 (하지만 여기서는 kademlia 에 종속된~ 디자인 실패?) WireInterface kademlia 에서 사용되는 주요 프로토콜을 선언해두고 있다. (send_ping,send_pong,send_fine_node,send_neighbours) 
6. KademliaProtocol 은 kademlia 노드디스커버리를 구현하며 RoutingTable 를 관리한다.  kademlia DHT 의 모든 구현이 되있는것은 아니고, 토렌트 같은 데이터를 찾고 저장하는 부분은 제외되있다. 
7. RoutingTable 은 내가 가까운 노드들을 많이 담고있고, 먼 노드들은 적게 담고 있는 테이블이다.
8. KBucket 은 RoutingTable 의 한개의 로우에 해당하며, 개수는 지정하기 나름이다. (보통 16개)
9. Node는 하나의 peer 를 나타내며, xor 연산을 하기 위한 id 를 가지고 있다. 이 노드를 상속받아서 더 구체적인 정보(ip,port,reputation등)를 가지고 있는 노드도 있다.

주요 소스를 살펴보자. 녹색 글짜로 주석을 달아 놓았으며 빠른 이해를 위해, 예외등을 삭제했다. 

2-1. NodeDiscovery 의 start (discovery.py 에 존재)


class NodeDiscovery(BaseService, DiscoveryProtocolTransport):
// 노드디스커버리를 위한 port 30303 을 설정한다.
default_config = dict(
discovery=dict(
listen_port=30303,
listen_host='0.0.0.0',
),
node=dict(privkey_hex=''))
// 생성자에서 DiscoveryProtocol 객체를 생성한다.
def __init__(self, app):
BaseService.__init__(self, app)
self.protocol = DiscoveryProtocol(app=self.app, transport=self)
     // 리모트 노드에 message 를 전송한다. 전송시에는 UDP server 를 이용한다.

def send(self, address, message):
self.server.sendto(message, (address.ip, address.udp_port))
// 리모트 노드에서 받은 message 를 kademlia 프로토콜 객체로 건네 준다. // 즉 비지니스 로직은 kademlia 프로토콜 객체에 있고, 이 NodeDiscovery 는 전달자,조정자의 역할이다.

def receive(self, address, message):
self.protocol.receive(address, message)

// NodeDiscovery 를 시작한다. // 1. upnp 설정을 하여 nat에서도 사용되게 한다. // 2 DatagrameServer 를 시작한다 // 3. kademlia protocol 을 이용하여 설정파일에 기록되있는 노드를 통해 bootstrap한다.

def start(self):
ip = self.app.config['discovery']['listen_host']
port = self.app.config['discovery']['listen_port']
self.nat_upnp = add_portmap(port, 'UDP', 'Ethereum DEVP2P Discovery')
self.server = DatagramServer((ip, port), handle=self._handle_packet)
self.server.start()
super(NodeDiscovery, self).start()
// bootstrap 시작한다.
nodes = [Node.from_uri(x) for x in self.app.config['discovery']['bootstrap_nodes']]
if nodes:
self.protocol.kademlia.bootstrap(nodes)


2-2. KademliaProtocol 의 bootstrap (kedemlia.py 에 존재)

// 가장 먼저 부트스트랩 노드를 일단 라우팅 테이블에 추가한다. (라우팅 테이블은 계속 업데이트 된다) // 그 후에 find_node 를 통해 자신과 가까운 노드들의 정보를 물어 본다. // find_node 의 첫번째 인자는 자신이고, 두번째는 부트스트랩 노드이다. // 즉 부트스트랩 노드에게 자신의 노드와 가까운 노드를 찾아 달라는 것이다.

def bootstrap(self, nodes):
for node in nodes:
if node == self.this_node:
continue
self.routing.add_node(node)
self.find_node(self.this_node.id, via_node=node)

이어서 find_node 를 살펴보자.

// via_node 에게 tergetid 와 가까운 노드를 찾아 달라는 것이다. 만약 특정 via_node가 없으면 // 자신의 라우팅테이블에서 검색해서 가까운 노드들에게 모두 find_node를 시키며, // via_node가 있으면 , 리모트 노드에게 물어본다. // 여기서 find_requests 는 일정시간동안 응답이 없으면 무효화하기 위함이다.

def find_node(self, targetid, via_node=None):
self._find_requests[targetid] = time.time() + k_request_timeout
if via_node:
self.wire.send_find_node(via_node, targetid)
else:
self._query_neighbours(targetid)

실제 메세지를 보내는 send_find_node 는 암호화 및  rlp 인코딩 후 UDP 를 사용할 것이다. 확인해보자.

// 해당 메세지를 packing 하여 UDP 로 보낸다. 여기서 .packing이 바로 rlp 인코딩이다. def send_find_node(self, node, target_node_id):
message = self.pack(self.cmd_id_map['find_node'], [target_node_id])
self.send(node, message)

rlp 인코딩 및 무결성 체크 데이터를 통해 최종 메세지 생성 (여기서는 비밀키를 통해 암호화는 하지 않는다. 나중에 peermanager 서비스에서는 ECDH 키교환에 의한 비밀키를 생성하여 암호화하는 부분을 볼 수 있을 것이다) 

//find_node 에 해당하는 cmd_id 와 실제 보내질 메세지를 인자로 받는다. // rlp.encode 를 통해 payload 를 인코딩한다. // 인코딩된 데이터를 sha3 로 서명하고 무결성을 위하여 MDC를 만든다. // 최종적으로 MDC + 서명 + CMD_ID + 실제 데이터 가 보내어진다.

def pack(self, cmd_id, payload):
cmd_id = str_to_bytes(self.encoders['cmd_id'](cmd_id))
expiration = self.encoders['expiration'](int(time.time() + self.expiration))
encoded_data = rlp.encode(payload + [expiration])
signed_data = crypto.sha3(cmd_id + encoded_data)
signature = crypto.sign(signed_data, self.privkey)
mdc = crypto.sha3(signature + cmd_id + encoded_data)
return mdc + signature + cmd_id + encoded_data

 query_neighbours 를 살펴보자. 자신의 라우팅 테이블에서 주어진 node_id와 그나마 가장 가까운 노드들에게, 자신과 진짜로 가까운 노드들이 또 있는지 찾아달라고 부탁한다.

// 라우팅테이블의 neighbours 메소드를 호출하여 가까운 것들을 찾아서 모두에게 find_node를 부탁한다. def _query_neighbours(self, targetid):
for n in self.routing.neighbours(targetid)[:k_find_concurrency]:
self.wire.send_find_node(n, targetid)

마지막으로 neighbours 는 이렇다.

// 일단 인자로 들어온 node와 가장가까운 버킷에서 가장 가까운 노드부터 채워넣는다. // k_bucket_size의 2배만큼~ 논문에는 k_bucket_size 는 16으로 되어있으며, 이더리움에서도 16을 쓴다. def neighbours(self, node, k=k_bucket_size):
nodes = []
for bucket in self.buckets_by_id_distance(node):
for n in bucket.nodes_by_id_distance(node):
if n is not node:
nodes.append(n)
if len(nodes) == k * 2:
break
return sorted(nodes, key=operator.methodcaller('id_distance', node))[:k]

Nodediscovery는 나름 단순하므로 나머지 부분은 각자 소스를 보면 되리라 본다.

3. PeerManager

PeerManager는 프로토콜 별로 framming 하는 부분이 소켓 프로그래밍에 있어서 꽤 교육적이고, 리플렉션을 통해 다중 프로토콜을 유연하게 확장 하려는 모습이 돋보여서 나름 읽어볼만하다. 개발자에게 오픈소스는 무협지 같은 소설 아니던가. ~


클래스 구성은 대략 위와 같으며 설명하자면 

1. PeerManager도 NodeManager와 마찬가지로 pydevp2p 에서 제공하는 서비스의 일종이다. 
2. PeerManager 는 StreamServer 를 통해 발생하는 명령들을 최종적으로 TCP를 이용하여 전송하고 받는다. 
3. PeerManager 서비스는 peer 들과 연결된 후에 각각 peer 객체를 생성해서 개별적으로 통신하게 한다.
4. BaseProtocol 은 모든 프로토콜이 가져야할 공통적인 메소드를 가지고 있으며, 하위 객체에서 생성하는 각각의 커맨드 프로토콜의 create/send/receive/receive_callback 메소드를 자동적으로 만들어 주는 리플렉션 기봅이 들어가 있다.
5. P2PProtocol 에는 핸드쉐이킹을 통해 상호 프로토콜 정보와 암호정보를 교환하는 프로토콜과 ping,pong 을 통해 연결을 확인하는 명령이 있다. 이 모든것은 MultiplexedSessiion 을 통해서 한다. rlp 인코딩을 한 메세지를 전달함
6. MultiplexedSession 을 통해서 비동기 i/o 를 처리해준다. 즉 read/write 에 대해 각각의 경량쓰레드를 만든 후에 해당 이벤트가 없는 경우 양보한다. read/write 과 RlpxSession 를 관리한다.
7. 또한 MultiplexedSession 을 통해서 보내야할 데이터를 조각낸다. 조각내는 이유는 소켓버퍼의 양(window size)에 맞추기 위함인데, 프로토콜 별로 조각, 우선순위별 조각등이 각각 조합되어 max_window_size 만큼의 패킷을 완성하게 된다. 해당 패킷은 StreamServer 를 통해 보내어진다. 즉 아래 요구사항을 만족. 

  • protocols sharing a connection are provided uniform bandwidth
  • Fair queuing

8. RlpxSession 에서는 RLP 를 통해 전송/수신 메세지에 대해 인코딩/디코딩하고, 상호동일하게 만들어진 비밀키를 통해 암호화 한다. MultiplexedSession 은 이 부분에 대해서 RlpxSession을 이용한다.
9. 위의 다이어그램에는 나오지 않았지만 커넥션이 맺어지고, hello를 하여 핸드쉐이크(프로토콜 및 비밀키 교환)를 완료하고 난 후 부터는 ConnectionMonitor 클래스를 통해서 ping,pong 을 하여 커넥션을 모니터링 한다.

간단히 정리하면 P2PProtocol 은 상위단의 비니지스 로직이고, MultiplexedSession 은 하위단의 패킷 조작자이다.이 모든것을 Peer가 컨트롤하며, 최종 메세지는 PeearManager가 가지고 있는 TCP 소켓(StreamServe)r을 통해서 보내어진다.

주요 소스를 살펴보자. (프로그램이 시작되고 나서 순서대로/몇몇 예외나 로직들이 생략되었다.)

3-1. PeerManager  (peermanger.py 에 존재)

def start(self):
# NAT 문제를 해결하기 위한 upnp 세팅.
self.nat_upnp = add_portmap(
self.config['p2p']['listen_port'],
'TCP',
'Ethereum DEVP2P Peermanager'
)
# TCP 서버의 listening 핸들러 설정. 새로운 접속이 들어오면 호출된다.
self.server.set_handle(self._on_new_connection) # TCP 서버 시작.
self.server.start()
# bootstrap 시작 (이미 하드코딩되어 있는 시작노드에 접속한다)
gevent.spawn_later(0.001, self._bootstrap, self.config['p2p']['bootstrap_nodes']) # discovery 시작 (새로 접속할 노드를 선택하기 위해 kademlia routing table 을 참조한다)
gevent.spawn_later(1, self._discovery_loop)

기본적으로 TCP 서버를 시작하고 있으며, 리모트 노드와 접속하기 위한 위한 경량 쓰레드를 각각 시작한다. 
다음으로 disovery_loop 를 따라가 보자. 


def _discovery_loop(self): # kademlia node discovery 가 어느정도 완성 될 때 까지 대기~
gevent.sleep(self.discovery_delay) # 이제 부터 계속 접속할 노드를 찾는 과정을 반복한다.
while not self.is_stopped:
# 이제 부터 계속 접속할 노드를 찾는 과정을 반복한다.
num_peers, min_peers = self.num_peers(), self.config['p2p']['min_peers'] # 접속할 노드를 선택하기 위해 미리 채워두었던 kademlia 프로토콜을 참조한다.
kademlia_proto = self.app.services.discovery.protocol.kademlia # 접속할 최소peer 수보다 많을 때 까지 추가한다.
if num_peers < min_peers: # 접속할 노드를 선택하기 위해 랜덤으로 node id 하나를 만든다.
nodeid = kademlia.random_nodeid() # kademlia routing table 에서 가까운 노드들을 가져온다.
kademlia_proto.find_node(nodeid) # fixme, should be a task
neighbours = kademlia_proto.routing.neighbours(nodeid, 2)
# 가져온 노드들 중에 노드 하나를 선택한다. 이 무작위로 선택한 노드와 연결될 것이다.
node = random.choice(neighbours)
# 내 public key를 구해서
local_pubkey = crypto.privtopub(decode_hex(self.config['node']['privkey_hex']))
# 선택된 노드와 연결한다. (그 노드에 내 public key 를 보낼 것이다.
self.connect((node.address.ip, node.address.tcp_port), node.pubkey)

Node discovery 프로토콜에서 채워놓은 라우팅테이블에서 가져온 노드들중에 랜덤으로 하나 선택하여 연결한다.
모든 노드들이 이렇게 연결하기 때문에, 토폴로지와 상관없이 골고루 연결될 수 있을 것이다. 아래에 해당된다.

  • nodes have access to a uniform network topology

다음으로 해당 노드와 연결하는 부분을 자세히 살펴보자.

def connect(self, address, remote_pubkey):
# 먼저 gevent 소켓통신 라이브러리에서 제공하는 create_connection 으로 연결정보(소켓디스크립션)를 얻는다.
connection = create_connection(address, timeout=self.connect_timeout) # 이제 해당 노드와 1대1로 통신하기 위한 Peer 객체를 생성한다.
self._start_peer(connection, address, remote_pubkey)
return True

다음으로  _start_peer 를 살펴보자.


def _start_peer(self, connection, address, remote_pubkey=None): # 이제 해당 노드와 1대1로 통신하기 위한 Peer 객체를 생성한다.
peer = Peer(self, connection, remote_pubkey=remote_pubkey)
# peer 경량 쓰레드를 시작한다.
peer.start()

Peer 객체를 시작하고 있다. 인자로는 연결된 상대노드의 public key 정보를 넣어준다. 참고로 이 키는 노드디스커버리 프로토콜에서 얻어졌다. 이제 Peer 객체를 자세히 살펴본다.

3-2. Peer (peer.py 에 존재)
아래는 peer 객체의 생성자이다. 생성자에서는 MultiplexedSession 을 이용하여 리모트 노드와 핸드쉐이크를 통해 상호 신뢰 할 수 있는 연결정보를 확보해 놓는데 이용한다.


def __init__(self, peermanager, connection, remote_pubkey=None):
super(Peer, self).__init__()
self.is_stopped = False
self.hello_received = False
self.peermanager = peermanager
self.connection = connection
self.config = peermanager.config
self.protocols = OrderedDict()


privkey = decode_hex(self.config['node']['privkey_hex']) # P2P Protocol 의 get_hello_packet 클래스메소드를 통해 hello 패킷을 만든다. 이 패킷은 rlp 인코딩되어 리턴된다.
hello_packet = P2PProtocol.get_hello_packet(
self) # MultiplexedSession 을 통해서 보내어 질 최종 메세지를 만들기 위한 각종 작업을 한다.(예를들어 framing) # 또한 앞으로 교환정보를 암호화할 비밀키 생성 및 프로토콜 교환을 위한 작업도 담당한다.
self.mux = MultiplexedSession(privkey, hello_packet, remote_pubkey=remote_pubkey)

3-3. MultiplexedSession(muxsession.py 에 존재)
아래는 muxsession 객체의 생성자이다. 내부에 ingress,egress 큐가 존재하여 multiplexing 작업을 할 수 있으며, RLPxSession 객체를 통해 초기 암호화/프로토콜 공유 핸드쉐이킹을 할 수 있게 된다. 또한 멀티프로토콜 끼리 공평하게 패킷공간을 차지하게 하기 위한 framing 도 시작된다. 

def __init__(self, privkey, hello_packet, remote_pubkey=None): # 내가 시작한 연결인가? 누군가에게요청되어 온 연결인가? # 내가 시작한 연결이라면 노드디스커버리 프로토콜에서 이미 얻어진 상대 public key를 가지고 있을 것이다.

self.is_initiator = bool(remote_pubkey)
self.hello_packet = hello_packet # 나가는 메세지에 대한 큐
self.message_queue = gevent.queue.Queue() # 들어오는 메세지에 대한 큐
self.packet_queue = gevent.queue.Queue() # 암호화/무결성화 에 사용되는 ECCx 객체 생성
ecc = ECCx(raw_privkey=privkey) # 비밀키/토큰 공유를 위한 RLPxSession 객체 생성
self.rlpx_session = RLPxSession(ecc, is_initiator=bool(remote_pubkey))
# 상대노드에게 msg 를 보낸다. (초기 핸드쉐이킹을 위한)
if self.is_initiator:
self._send_init_msg()

이제 _send_init_msg 를 살펴본다.

def _send_init_msg(self): # RLPxSession 객체를 통해 인증메세지를 만든다. 이 메세지는 아주 많은 정보가 포함되는데 # 아래와 같다. # auth_message = S + sha3(ephemeral_pubkey) + self.ecc.raw_pubkey + \ self.initiator_nonce + ascii_chr(flag)
auth_msg =
self.rlpx_session.create_auth_message(self._remote_pubkey) # 인증메세지를 암호화 한다.

auth_msg_ct = self.rlpx_session.encrypt_auth_message(auth_msg) # 암호화된 메세지를 멀티플랙싱을 위한 egress 큐에 넣는다.
self.message_queue.put(auth_msg_ct)


마지막으로 핸드쉐이킹 부분을 살펴보자. 노드에서 초기 핸드세이크를 할 경우에는
add_mesasge 메소드가 add_message_during_handshake 로직을 따르고, 핸드쉐이크가 끝난 후에는 
add_message_post_handshake 로직을 따르게 된다.

핸드쉐이킹은 전반적으로 아래와 같으며, 

# 핸드쉐이킹 도중에는 받은 메세지를 이렇게 처리한다.
def _add_message_during_handshake(self, msg):
session =
self.rlpx_session # 내가 먼저 요청한 연결이라면
if self.is_initiator:
# auth 요청보낸것에 대한 답변인 msg 를 디코드 한다.
rest = session.decode_auth_ack_message(msg) # 리모트에서 넘어온 정보를 토대로 최종적인 상호 암호화 정보를 세팅하게 된다. 앞으로는 구축된 이 정보를 통해 # 상호간 암호화/무결성화된 통신을 하게 될 것이다.
session.setup_cipher()
if len(rest) > 0: # add remains (hello) to queue
self._add_message_post_handshake(rest)
#핸드쉐이킹 요청을 받은 것이라면 else:
# 요청된 auth 정보를 디코딩한다.
rest = session.decode_authentication(msg) # 대답해줄 ack 메세지를 준비한다.
auth_ack_msg = session.create_auth_ack_message()
# 대답해줄 ack 메세지를 암호화 한다.
auth_ack_msg_ct = session.encrypt_auth_ack_message(auth_ack_msg)
# 전송 큐인 message_queue에 넣는다.
self.message_queue.put(auth_ack_msg_ct) # 리모트로 부터 받은 auth 정보를 토대로 cipher 를 세팅한다.
session.setup_cipher()

self.add_message = self._add_message_post_handshake # 핸드쉐이크 종료후 add_messsage 메소드 # 암호화를 위한 핸드쉐이크가 끝나면 이제 프로토콜을 맞춰보는 핸드쉐이크가 시작된다. self.add_packet(self.hello_packet) add_message = _add_message_during_handshake # 초기 add_messsage 메소드 # 핸드쉐이킹이 끝난 후에는 아주 단순하게 그냥 전달받은 msg 를 디코드하여 수신큐인 packet_queue에 넣는다. def _add_message_post_handshake(self, msg): for packet in self.decode(msg): self.packet_queue.put(packet)


첫번째 핸드쉐이킹은 암호화를 위한 정보공유이다. ECDH 를 이용하여 양쪽 노드끼리 동일한 비밀키를 만든다.
리모트에게 보내 줄 auth 메세지를 만드는 아래 함수를 보자.

def create_auth_message(self, remote_pubkey, ephemeral_privkey=None, nonce=None):

if not self.ecc.is_valid_key(remote_pubkey):
raise InvalidKeyError('invalid remote pubkey')
self.remote_pubkey = remote_pubkey

ecdh_shared_secret = self.ecc.get_ecdh_key(remote_pubkey)
token = ecdh_shared_secret
flag = 0x0
self.initiator_nonce = nonce or sha3(ienc(random.randint(0, 2 ** 256 - 1)))
assert len(self.initiator_nonce) == 32

token_xor_nonce = sxor(token, self.initiator_nonce)
assert len(token_xor_nonce) == 32

ephemeral_pubkey = self.ephemeral_ecc.raw_pubkey
assert len(ephemeral_pubkey) == 512 / 8
if not self.ecc.is_valid_key(ephemeral_pubkey):
raise InvalidKeyError('invalid ephemeral pubkey')

# S(ephemeral-privk, ecdh-shared-secret ^ nonce)
S = self.ephemeral_ecc.sign(token_xor_nonce)
assert len(S) == 65

# S || H(ephemeral-pubk) || pubk || nonce || 0x0
auth_message = S + sha3(ephemeral_pubkey) + self.ecc.raw_pubkey + \
self.initiator_nonce + ascii_chr(flag)
assert len(auth_message) == 65 + 32 + 64 + 32 + 1 == 194
return auth_message

이렇게 나의 암호화 정보에 대한 패킷을 만들어 보내면, 상대방은 이것을 디코딩하여 자신에게 적용하고, 다시 나에게 자신의 정보를 ack 로 보내주면 나 역시 상대방의 정보를 이용하여 암호화 토큰을 완성시킨다. 솔직히 이렇게 까지 감싸고 감싸야 하는지에 대해서는 갸우뚱 하다. 

두번째 핸드쉐이킹은 상호 적용 가능한 wire protocol 에 대한 정보교환을 한다. 기본적인 와이어 프로토콜에는 P2PProtocol 이 있다. 

@classmethod
def get_hello_packet(cls, peer):
"special: we need this packet before the protocol can be initalized"
res = dict(version=cls.version,
client_version_string=peer.config['client_version_string'],
capabilities=peer.capabilities,
listen_port=peer.config['p2p']['listen_port'],
remote_pubkey=peer.config['node']['id'])
payload = cls.hello.encode_payload(res)
return Packet(cls.protocol_id, cls.hello.cmd_id, payload=payload)

리모트 노드에 내 정보를 보내 준다. capabilities 에 내가 제공하고 있는 와이어 프로토콜의 정보가 담긴다.

그 후에  리모트로 부터 받은 상대방의 capabilities (와이어프로토콜집합)을 등록하고, 기본 프로토콜(P2PProtocol) 이외의 서비스가 서로간에 존재한다면 그것에 대한 서비스요청을 한다. 이렇게 추가된 프로토콜은 여러 프로토콜이 공평하게 메세지를 전달할 수 있도록 Framming 전략에 따라 패킷을 처리된다. 즉 공평하게 큐에 넣어지고, 균등하게 대역폭이 제공된다.

def receive_hello(self, proto, version, client_version_string, capabilities,
listen_port, remote_pubkey):


self.remote_client_version = client_version_string
self.remote_pubkey = remote_pubkey
self.remote_capabilities = capabilities

# 리모트 서비스를 등록한다.
remote_services = dict()
for name, version in capabilities:
remote_services[name].append(version) # p2p protocol 은 기본적으로 있기 때문에, 다른 서비스가 상호간에 존재한다면 그것에 대한 접속 요청.
for service in sorted(self.peermanager.wired_services, key=operator.attrgetter('name')):
proto = service.wire_protocol
if proto.name in remote_services:
if proto.version in remote_services[proto.name]:
if service != self.peermanager: # p2p protocol already registered
self.connect_service(service)
else:
log.debug('wrong version', service=proto.name, local_version=proto.version,
remote_version=remote_services[proto.name])
self.report_error('wrong version')

자 이렇게 전반적인 devp2p 파이썬 구현체를 살펴보았다. 더 궁금한 점은 직접 소스를 살펴보길 바라며, (Framing 부분도 매우 흥미진진하다는 팁을 귀뜸해드린다.)  다음 글에는 [go,rust,c++에서 구현된 devp2p], [devp2p 를 이용하는 blocksync ],[SWARM vs libp2p 를 이용한 IPFS] 에 대해 말해볼 예정이다.

Comments