以太坊原始碼分析(47)p2p-rlpx節點之間的加密鏈路
RLPx Encryption(RLPx加密)
之前介紹的discover節點發現協議, 因為承載的資料不是很重要,基本是明文傳輸的。
每一個節點會開啟兩個同樣的埠,一個是UDP埠,用來節點發現,一個是TCP埠,用來承載業務資料。 UDP的埠和TCP的埠的埠號是同樣的。 這樣只要通過UDP發現了埠,就等於可以用TCP來連線到對應的埠。
RLPx協議就定義了TCP連結的加密過程。
RLPx使用了(Perfect Forward Secrecy), 簡單來說。 連結的兩方生成生成隨機的私鑰,通過隨機的私鑰得到公鑰。 然後雙方交換各自的公鑰, 這樣雙方都可以通過自己隨機的私鑰和對方的公鑰來生成一個同樣的共享金鑰(shared-secret)。後續的通訊使用這個共享金鑰作為對稱加密演算法的金鑰。 這樣來說。如果有一天一方的私鑰被洩露,也只會影響洩露之後的訊息的安全性, 對於之前的通訊是安全的(因為通訊的金鑰是隨機生成的,用完後就消失了)。
## 前向安全性(引用自維基百科)
前向安全或前向保密(英語:Forward Secrecy,縮寫:FS),有時也被稱為完美前向安全[1](英語:Perfect Forward Secrecy,縮寫:PFS),是密碼學中通訊協議的安全屬性,指的是長期使用的主金鑰洩漏不會導致過去的會話金鑰洩漏。[2]前向安全能夠保護過去進行的通訊不受密碼或金鑰在未來暴露的威脅。[3]如果系統具有前向安全性,就可以保證萬一密碼或金鑰在某個時刻不慎洩露,過去已經進行的通訊依然是安全,不會受到任何影響,即使系統遭到主動攻擊也是如此。
### 迪菲-赫爾曼金鑰交換
迪菲-赫爾曼金鑰交換(英語:Diffie–Hellman key exchange,縮寫為D-H) 是一種安全協議。它可以讓雙方在完全沒有對方任何預先資訊的條件下通過不安全通道建立起一個金鑰。這個金鑰可以在後續的通訊中作為對稱金鑰來加密通訊內容。公鑰交換的概念最早由瑞夫·墨克(Ralph C. Merkle)提出,而這個金鑰交換方法,由惠特菲爾德·迪菲(Bailey Whitfield Diffie)和馬丁·赫爾曼(Martin Edward Hellman)在1976年首次發表。馬丁·赫爾曼曾主張這個金鑰交換方法,應被稱為迪菲-赫爾曼-墨克金鑰交換(英語:Diffie–Hellman–Merkle key exchange)。
- 迪菲-赫爾曼金鑰交換的同義詞包括:
- 迪菲-赫爾曼金鑰協商
- 迪菲-赫爾曼金鑰建立
- 指數金鑰交換
- 迪菲-赫爾曼協議
雖然迪菲-赫爾曼金鑰交換本身是一個匿名(無認證)的金鑰交換協議,它卻是很多認證協議的基礎,並且被用來提供傳輸層安全協議的短暫模式中的完備的前向安全性。
#### 描述
迪菲-赫爾曼通過公共通道交換一個資訊,就可以建立一個可以用於在公共通道上安全通訊的共享祕密(shared secret)。
## p2p/rlpx.go原始碼解讀
這個檔案實現了RLPx的鏈路協議。
連結聯絡的大致流程如下:
1. doEncHandshake() 通過這個方法來完成交換金鑰,建立加密通道的流程。如果失敗,那麼連結關閉。
2. doProtoHandshake() 這個方法來進行協議特性之間的協商,比如雙方的協議版本,是否支援Snappy加密方式等操作。
連結經過這兩次處理之後,就算建立起來了。因為TCP是流式的協議。所有RLPx協議定義了分幀的方式。所有的資料都可以理解為一個接一個的rlpxFrame。 rlpx的讀寫都是通過rlpxFrameRW物件來進行處理。
### doEncHandshake
連結的發起者被稱為initiator。連結的被動接受者被成為receiver。 這兩種模式下處理的流程是不同的。完成握手後。 生成了一個sec.可以理解為拿到了對稱加密的金鑰。 然後建立了一個newRLPXFrameRW幀讀寫器。完成加密通道的建立過程。
func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *discover.Node) (discover.NodeID, error) {
var (
sec secrets
err error
)
if dial == nil {
sec, err = receiverEncHandshake(t.fd, prv, nil)
} else {
sec, err = initiatorEncHandshake(t.fd, prv, dial.ID, nil)
}
if err != nil {
return discover.NodeID{}, err
}
t.wmu.Lock()
t.rw = newRLPXFrameRW(t.fd, sec)
t.wmu.Unlock()
return sec.RemoteID, nil
}
initiatorEncHandshake 首先看看連結的發起者的操作。首先通過makeAuthMsg建立了authMsg。 然後通過網路傳送給對端。然後通過readHandshakeMsg讀取對端的迴應。 最後呼叫secrets建立了共享祕密。
// initiatorEncHandshake negotiates a session token on conn.
// it should be called on the dialing side of the connection.
//
// prv is the local client's private key.
func initiatorEncHandshake(conn io.ReadWriter, prv *ecdsa.PrivateKey, remoteID discover.NodeID, token []byte) (s secrets, err error) {
h := &encHandshake{initiator: true, remoteID: remoteID}
authMsg, err := h.makeAuthMsg(prv, token)
if err != nil {
return s, err
}
authPacket, err := sealEIP8(authMsg, h)
if err != nil {
return s, err
}
if _, err = conn.Write(authPacket); err != nil {
return s, err
}
authRespMsg := new(authRespV4)
authRespPacket, err := readHandshakeMsg(authRespMsg, encAuthRespLen, prv, conn)
if err != nil {
return s, err
}
if err := h.handleAuthResp(authRespMsg); err != nil {
return s, err
}
return h.secrets(authPacket, authRespPacket)
}
makeAuthMsg。這個方法建立了initiator的handshake message。 首先對端的公鑰可以通過對端的ID來獲取。所以對端的公鑰對於發起連線的人來說是知道的。 但是對於被連線的人來說,對端的公鑰應該是不知道的。
// makeAuthMsg creates the initiator handshake message.
func (h *encHandshake) makeAuthMsg(prv *ecdsa.PrivateKey, token []byte) (*authMsgV4, error) {
rpub, err := h.remoteID.Pubkey()
if err != nil {
return nil, fmt.Errorf("bad remoteID: %v", err)
}
h.remotePub = ecies.ImportECDSAPublic(rpub)
// Generate random initiator nonce.
// 生成一個隨機的初始值, 是為了避免重放攻擊麼? 還是為了避免通過多次連線猜測金鑰?
h.initNonce = make([]byte, shaLen)
if _, err := rand.Read(h.initNonce); err != nil {
return nil, err
}
// Generate random keypair to for ECDH.
//生成一個隨機的私鑰
h.randomPrivKey, err = ecies.GenerateKey(rand.Reader, crypto.S256(), nil)
if err != nil {
return nil, err
}
// Sign known message: static-shared-secret ^ nonce
// 這個地方應該是直接使用了靜態的共享祕密。 使用自己的私鑰和對方的公鑰生成的一個共享祕密。
token, err = h.staticSharedSecret(prv)
if err != nil {
return nil, err
}
//這裡我理解用共享祕密來加密這個initNonce。
signed := xor(token, h.initNonce)
// 使用隨機的私鑰來加密這個資訊。
signature, err := crypto.Sign(signed, h.randomPrivKey.ExportECDSA())
if err != nil {
return nil, err
}
msg := new(authMsgV4)
copy(msg.Signature[:], signature)
//這裡把發起者的公鑰告知對方。 這樣對方使用自己的私鑰和這個公鑰可以生成靜態的共享祕密。
copy(msg.InitiatorPubkey[:], crypto.FromECDSAPub(&prv.PublicKey)[1:])
copy(msg.Nonce[:], h.initNonce)
msg.Version = 4
return msg, nil
}
// staticSharedSecret returns the static shared secret, the result
// of key agreement between the local and remote static node key.
func (h *encHandshake) staticSharedSecret(prv *ecdsa.PrivateKey) ([]byte, error) {
return ecies.ImportECDSA(prv).GenerateShared(h.remotePub, sskLen, sskLen)
}
sealEIP8方法,這個方法是一個組包方法,對msg進行rlp的編碼。 填充一些資料。 然後使用對方的公鑰把資料進行加密。 這意味著只有對方的私鑰才能解密這段資訊。
func sealEIP8(msg interface{}, h *encHandshake) ([]byte, error) {
buf := new(bytes.Buffer)
if err := rlp.Encode(buf, msg); err != nil {
return nil, err
}
// pad with random amount of data. the amount needs to be at least 100 bytes to make
// the message distinguishable from pre-EIP-8 handshakes.
pad := padSpace[:mrand.Intn(len(padSpace)-100)+100]
buf.Write(pad)
prefix := make([]byte, 2)
binary.BigEndian.PutUint16(prefix, uint16(buf.Len()+eciesOverhead))
enc, err := ecies.Encrypt(rand.Reader, h.remotePub, buf.Bytes(), nil, prefix)
return append(prefix, enc...), err
}
readHandshakeMsg這個方法會從兩個地方呼叫。 一個是在initiatorEncHandshake。一個就是在receiverEncHandshake。 這個方法比較簡單。 首先用一種格式嘗試解碼。如果不行就換另外一種。應該是一種相容性的設定。 基本上就是使用自己的私鑰進行解碼然後呼叫rlp解碼成結構體。 結構體的描述就是下面的authRespV4,裡面最重要的就是對端的隨機公鑰。 雙方通過自己的私鑰和對端的隨機公鑰可以得到一樣的共享祕密。 而這個共享祕密是第三方拿不到的。
// RLPx v4 handshake response (defined in EIP-8).
type authRespV4 struct {
RandomPubkey [pubLen]byte
Nonce [shaLen]byte
Version uint
// Ignore additional fields (forward-compatibility)
Rest []rlp.RawValue `rlp:"tail"`
}
func readHandshakeMsg(msg plainDecoder, plainSize int, prv *ecdsa.PrivateKey, r io.Reader) ([]byte, error) {
buf := make([]byte, plainSize)
if _, err := io.ReadFull(r, buf); err != nil {
return buf, err
}
// Attempt decoding pre-EIP-8 "plain" format.
key := ecies.ImportECDSA(prv)
if dec, err := key.Decrypt(rand.Reader, buf, nil, nil); err == nil {
msg.decodePlain(dec)
return buf, nil
}
// Could be EIP-8 format, try that.
prefix := buf[:2]
size := binary.BigEndian.Uint16(prefix)
if size < uint16(plainSize) {
return buf, fmt.Errorf("size underflow, need at least %d bytes", plainSize)
}
buf = append(buf, make([]byte, size-uint16(plainSize)+2)...)
if _, err := io.ReadFull(r, buf[plainSize:]); err != nil {
return buf, err
}
dec, err := key.Decrypt(rand.Reader, buf[2:], nil, prefix)
if err != nil {
return buf, err
}
// Can't use rlp.DecodeBytes here because it rejects
// trailing data (forward-compatibility).
s := rlp.NewStream(bytes.NewReader(dec), 0)
return buf, s.Decode(msg)
}
handleAuthResp這個方法非常簡單。
func (h *encHandshake) handleAuthResp(msg *authRespV4) (err error) {
h.respNonce = msg.Nonce[:]
h.remoteRandomPub, err = importPublicKey(msg.RandomPubkey[:])
return err
}
最後是secrets函式,這個函式是在handshake完成之後呼叫。它通過自己的隨機私鑰和對端的公鑰來生成一個共享祕密,這個共享祕密是瞬時的(只在當前這個連結中存在)。所以當有一天私鑰被破解。 之前的訊息還是安全的。
// secrets is called after the handshake is completed.
// It extracts the connection secrets from the handshake values.
func (h *encHandshake) secrets(auth, authResp []byte) (secrets, error) {
ecdheSecret, err := h.randomPrivKey.GenerateShared(h.remoteRandomPub, sskLen, sskLen)
if err != nil {
return secrets{}, err
}
// derive base secrets from ephemeral key agreement
sharedSecret := crypto.Keccak256(ecdheSecret, crypto.Keccak256(h.respNonce, h.initNonce))
aesSecret := crypto.Keccak256(ecdheSecret, sharedSecret)
// 實際上這個MAC保護了ecdheSecret這個共享祕密。respNonce和initNonce這三個值
s := secrets{
RemoteID: h.remoteID,
AES: aesSecret,
MAC: crypto.Keccak256(ecdheSecret, aesSecret),
}
// setup sha3 instances for the MACs
mac1 := sha3.NewKeccak256()
mac1.Write(xor(s.MAC, h.respNonce))
mac1.Write(auth)
mac2 := sha3.NewKeccak256()
mac2.Write(xor(s.MAC, h.initNonce))
mac2.Write(authResp)
//收到的每個包都會檢查其MAC值是否滿足計算的結果。如果不滿足說明有問題。
if h.initiator {
s.EgressMAC, s.IngressMAC = mac1, mac2
} else {
s.EgressMAC, s.IngressMAC = mac2, mac1
}
return s, nil
}
receiverEncHandshake函式和initiatorEncHandshake的內容大致相同。 但是順序有些不一樣。
// receiverEncHandshake negotiates a session token on conn.
// it should be called on the listening side of the connection.
//
// prv is the local client's private key.
// token is the token from a previous session with this node.
func receiverEncHandshake(conn io.ReadWriter, prv *ecdsa.PrivateKey, token []byte) (s secrets, err error) {
authMsg := new(authMsgV4)
authPacket, err := readHandshakeMsg(authMsg, encAuthMsgLen, prv, conn)
if err != nil {
return s, err
}
h := new(encHandshake)
if err := h.handleAuthMsg(authMsg, prv); err != nil {
return s, err
}
authRespMsg, err := h.makeAuthResp()
if err != nil {
return s, err
}
var authRespPacket []byte
if authMsg.gotPlain {
authRespPacket, err = authRespMsg.sealPlain(h)
} else {
authRespPacket, err = sealEIP8(authRespMsg, h)
}
if err != nil {
return s, err
}
if _, err = conn.Write(authRespPacket); err != nil {
return s, err
}
return h.secrets(authPacket, authRespPacket)
}
### doProtocolHandshake
這個方法比較簡單, 加密通道已經建立完畢。 我們看到這裡只是約定了是否使用Snappy加密然後就退出了。
// doEncHandshake runs the protocol handshake using authenticated
// messages. the protocol handshake is the first authenticated message
// and also verifies whether the encryption handshake 'worked' and the
// remote side actually provided the right public key.
func (t *rlpx) doProtoHandshake(our *protoHandshake) (their *protoHandshake, err error) {
// Writing our handshake happens concurrently, we prefer
// returning the handshake read error. If the remote side
// disconnects us early with a valid reason, we should return it
// as the error so it can be tracked elsewhere.
werr := make(chan error, 1)
go func() { werr <- Send(t.rw, handshakeMsg, our) }()
if their, err = readProtocolHandshake(t.rw, our); err != nil {
<-werr // make sure the write terminates too
return nil, err
}
if err := <-werr; err != nil {
return nil, fmt.Errorf("write error: %v", err)
}
// If the protocol version supports Snappy encoding, upgrade immediately
t.rw.snappy = their.Version >= snappyProtocolVersion
return their, nil
}
### rlpxFrameRW 資料分幀
資料分幀主要通過rlpxFrameRW類來完成的。
// rlpxFrameRW implements a simplified version of RLPx framing.
// chunked messages are not supported and all headers are equal to
// zeroHeader.
//
// rlpxFrameRW is not safe for concurrent use from multiple goroutines.
type rlpxFrameRW struct {
conn io.ReadWriter
enc cipher.Stream
dec cipher.Stream
macCipher cipher.Block
egressMAC hash.Hash
ingressMAC hash.Hash
snappy bool
}
我們在完成兩次握手之後。呼叫newRLPXFrameRW方法建立了這個物件。
t.rw = newRLPXFrameRW(t.fd, sec)
然後提供ReadMsg和WriteMsg方法。這兩個方法直接呼叫了rlpxFrameRW的ReadMsg和WriteMsg
func (t *rlpx) ReadMsg() (Msg, error) {
t.rmu.Lock()
defer t.rmu.Unlock()
t.fd.SetReadDeadline(time.Now().Add(frameReadTimeout))
return t.rw.ReadMsg()
}
func (t *rlpx) WriteMsg(msg Msg) error {
t.wmu.Lock()
defer t.wmu.Unlock()
t.fd.SetWriteDeadline(time.Now().Add(frameWriteTimeout))
return t.rw.WriteMsg(msg)
}
WriteMsg
func (rw *rlpxFrameRW) WriteMsg(msg Msg) error {
ptype, _ := rlp.EncodeToBytes(msg.Code)
// if snappy is enabled, compress message now
if rw.snappy {
if msg.Size > maxUint24 {
return errPlainMessageTooLarge
}
payload, _ := ioutil.ReadAll(msg.Payload)
payload = snappy.Encode(nil, payload)
msg.Payload = bytes.NewReader(payload)
msg.Size = uint32(len(payload))
}
// write header
headbuf := make([]byte, 32)
fsize := uint32(len(ptype)) + msg.Size
if fsize > maxUint24 {
return errors.New("message size overflows uint24")
}
putInt24(fsize, headbuf) // TODO: check overflow
copy(headbuf[3:], zeroHeader)
rw.enc.XORKeyStream(headbuf[:16], headbuf[:16]) // first half is now encrypted
// write header MAC
copy(headbuf[16:], updateMAC(rw.egressMAC, rw.macCipher, headbuf[:16]))
if _, err := rw.conn.Write(headbuf); err != nil {
return err
}
// write encrypted frame, updating the egress MAC hash with
// the data written to conn.
tee := cipher.StreamWriter{S: rw.enc, W: io.MultiWriter(rw.conn, rw.egressMAC)}
if _, err := tee.Write(ptype); err != nil {
return err
}
if _, err := io.Copy(tee, msg.Payload); err != nil {
return err
}
if padding := fsize % 16; padding > 0 {
if _, err := tee.Write(zero16[:16-padding]); err != nil {
return err
}
}
// write frame MAC. egress MAC hash is up to date because
// frame content was written to it as well.
fmacseed := rw.egressMAC.Sum(nil)
mac := updateMAC(rw.egressMAC, rw.macCipher, fmacseed)
_, err := rw.conn.Write(mac)
return err
}
ReadMsg
func (rw *rlpxFrameRW) ReadMsg() (msg Msg, err error) {
// read the header
headbuf := make([]byte, 32)
if _, err := io.ReadFull(rw.conn, headbuf); err != nil {
return msg, err
}
// verify header mac
shouldMAC := updateMAC(rw.ingressMAC, rw.macCipher, headbuf[:16])
if !hmac.Equal(shouldMAC, headbuf[16:]) {
return msg, errors.New("bad header MAC")
}
rw.dec.XORKeyStream(headbuf[:16], headbuf[:16]) // first half is now decrypted
fsize := readInt24(headbuf)
// ignore protocol type for now
// read the frame content
var rsize = fsize // frame size rounded up to 16 byte boundary
if padding := fsize % 16; padding > 0 {
rsize += 16 - padding
}
framebuf := make([]byte, rsize)
if _, err := io.ReadFull(rw.conn, framebuf); err != nil {
return msg, err
}
// read and validate frame MAC. we can re-use headbuf for that.
rw.ingressMAC.Write(framebuf)
fmacseed := rw.ingressMAC.Sum(nil)
if _, err := io.ReadFull(rw.conn, headbuf[:16]); err != nil {
return msg, err
}
shouldMAC = updateMAC(rw.ingressMAC, rw.macCipher, fmacseed)
if !hmac.Equal(shouldMAC, headbuf[:16]) {
return msg, errors.New("bad frame MAC")
}
// decrypt frame content
rw.dec.XORKeyStream(framebuf, framebuf)
// decode message code
content := bytes.NewReader(framebuf[:fsize])
if err := rlp.Decode(content, &msg.Code); err != nil {
return msg, err
}
msg.Size = uint32(content.Len())
msg.Payload = content
// if snappy is enabled, verify and decompress message
if rw.snappy {
payload, err := ioutil.ReadAll(msg.Payload)
if err != nil {
return msg, err
}
size, err := snappy.DecodedLen(payload)
if err != nil {
return msg, err
}
if size > int(maxUint24) {
return msg, errPlainMessageTooLarge
}
payload, err = snappy.Decode(nil, payload)
if err != nil {
return msg, err
}
msg.Size, msg.Payload = uint32(size), bytes.NewReader(payload)
}
return msg, nil
}
幀結構
normal = not chunked
chunked-0 = First frame of a multi-frame packet
chunked-n = Subsequent frames for multi-frame packet
|| is concatenate
^ is xor
Single-frame packet:
header || header-mac || frame || frame-mac
Multi-frame packet:
header || header-mac || frame-0 ||
[ header || header-mac || frame-n || ... || ]
header || header-mac || frame-last || frame-mac
header: frame-size || header-data || padding
frame-size: 3-byte integer size of frame, big endian encoded (excludes padding)
header-data:
normal: rlp.list(protocol-type[, context-id])
chunked-0: rlp.list(protocol-type, context-id, total-packet-size)
chunked-n: rlp.list(protocol-type, context-id)
values:
protocol-type: < 2**16
context-id: < 2**16 (optional for normal frames)
total-packet-size: < 2**32
padding: zero-fill to 16-byte boundary
header-mac: right128 of egress-mac.update(aes(mac-secret,egress-mac) ^ header-ciphertext).digest
frame:
normal: rlp(packet-type) [|| rlp(packet-data)] || padding
chunked-0: rlp(packet-type) || rlp(packet-data...)
chunked-n: rlp(...packet-data) || padding
padding: zero-fill to 16-byte boundary (only necessary for last frame)
frame-mac: right128 of egress-mac.update(aes(mac-secret,egress-mac) ^ right128(egress-mac.update(frame-ciphertext).digest))
egress-mac: h256, continuously updated with egress-bytes*
ingress-mac: h256, continuously updated with ingress-bytes*
因為加密解密演算法我也不是很熟,所以這裡的分析還不是很徹底。 暫時只是分析了大致的流程。還有很多細節沒有確認。
相關文章
- 死磕以太坊原始碼分析之p2p節點發現原始碼
- 以太坊原始碼分析(15)node包建立多重協議以太坊節點原始碼協議
- 死磕以太坊原始碼分析之txpool原始碼
- 死磕以太坊原始碼分析之state原始碼
- 以太坊原始碼分析(36)ethdb原始碼分析原始碼
- 以太坊原始碼分析(38)event原始碼分析原始碼
- 以太坊原始碼分析(41)hashimoto原始碼分析原始碼
- 以太坊原始碼分析(43)node原始碼分析原始碼
- 以太坊原始碼分析(52)trie原始碼分析原始碼
- 死磕以太坊原始碼分析之挖礦流程分析原始碼
- 以太坊公鏈節點連線節點超時問題排查
- 以太坊原始碼分析(51)rpc原始碼分析原始碼RPC
- 以太坊原始碼分析(21)chain_indexer 區塊鏈索引原始碼AIIndex區塊鏈索引
- 死磕以太坊原始碼分析之MPT樹-上原始碼
- 死磕以太坊原始碼分析之rlpx協議原始碼協議
- 死磕以太坊原始碼分析之Fetcher同步原始碼
- 死磕以太坊原始碼分析之downloader同步原始碼
- 以太坊原始碼分析(18)以太坊交易執行分析原始碼
- 以太坊原始碼分析(37)eth以太坊協議分析原始碼協議
- 以太坊原始碼分析(20)core-bloombits原始碼分析原始碼OOM
- 以太坊原始碼分析(24)core-state原始碼分析原始碼
- 以太坊原始碼分析(29)core-vm原始碼分析原始碼
- 以太坊原始碼分析(5)accounts程式碼分析原始碼
- 以太坊交易池原始碼分析原始碼
- 以太坊原始碼分析(23)core-state-process原始碼分析原始碼
- 以太坊原始碼分析(34)eth-downloader原始碼分析原始碼
- 以太坊原始碼分析(35)eth-fetcher原始碼分析原始碼
- 死磕以太坊原始碼分析之EVM指令集原始碼
- 以太坊原始碼分析(8)區塊分析原始碼
- 以太坊原始碼分析(9)cmd包分析原始碼
- 以太坊原始碼分析(13)RPC分析原始碼RPC
- 以太坊原始碼分析(16)挖礦分析原始碼
- 以太坊原始碼分析(53)以太坊測試網路Clique_PoA介紹原始碼
- 以太坊原始碼分析(26)core-txpool交易池原始碼分析原始碼
- 以太坊原始碼分析(28)core-vm-stack-memory原始碼分析原始碼
- 以太坊原始碼分析(30)eth-bloombits和filter原始碼分析原始碼OOMFilter
- 以太坊原始碼分析(31)eth-downloader-peer原始碼分析原始碼
- 以太坊原始碼分析(32)eth-downloader-peer原始碼分析原始碼