@ethereumjs/devp2p
Version:
A JavaScript implementation of ÐΞVp2p
742 lines (667 loc) • 22 kB
text/typescript
import { RLP } from '@ethereumjs/rlp'
import {
EthereumJSErrorWithoutCode,
bytesToHex,
bytesToInt,
bytesToUtf8,
concatBytes,
equalsBytes,
hexToBytes,
intToBytes,
utf8ToBytes,
} from '@ethereumjs/util'
import debugDefault from 'debug'
import { EventEmitter } from 'eventemitter3'
import * as snappy from 'snappyjs'
import { DISCONNECT_REASON, DisconnectReasonNames } from '../types.ts'
import { devp2pDebug, formatLogData } from '../util.ts'
import { ECIES } from './ecies.ts'
import type { Socket } from 'net'
import type { Common } from '@ethereumjs/common'
import type { Debugger } from 'debug'
import type { Protocol } from '../protocol/protocol.ts'
import type { Capabilities, PeerOptions } from '../types.ts'
const DEBUG_BASE_NAME = 'rlpx:peer'
const verbose = debugDefault('verbose').enabled
const BASE_PROTOCOL_VERSION = 5
const BASE_PROTOCOL_LENGTH = 16
const PING_INTERVAL = 15000 // 15 sec * 1000
export type PREFIXES = (typeof PREFIXES)[keyof typeof PREFIXES]
export const PREFIXES = {
HELLO: 0x00,
DISCONNECT: 0x01,
PING: 0x02,
PONG: 0x03,
} as const
// Reverse mapping: numeric value -> key name
export const PrefixesNames: { [key in PREFIXES]: string } = Object.entries(PREFIXES).reduce(
(acc, [key, value]) => {
acc[value as PREFIXES] = key
return acc
},
{} as { [key in PREFIXES]: string },
)
type HelloMsg = {
0: Uint8Array
1: Uint8Array
2: Uint8Array[][]
3: Uint8Array
4: Uint8Array
length: 5
}
interface ProtocolDescriptor {
protocol: Protocol
offset: number
length?: number
}
interface Hello {
protocolVersion: number
clientId: string
capabilities: Capabilities[]
port: number
id: Uint8Array
}
export class Peer {
public events: EventEmitter
public readonly clientId: Uint8Array
protected _capabilities?: Capabilities[]
public common: Common
protected _port: number
public readonly id: Uint8Array
protected _remoteClientIdFilter?: string[]
protected _remoteId: Uint8Array
protected _EIP8: Uint8Array | boolean
protected _eciesSession: ECIES
protected _state: string
protected _weHello: HelloMsg | null
protected _hello: Hello | null
protected _nextPacketSize: number
protected _socket: Socket
protected _socketData: Uint8Array
protected _pingIntervalId: NodeJS.Timeout | null
protected _pingTimeoutId: NodeJS.Timeout | null
protected _closed: boolean
protected _connected: boolean
protected _disconnectReason?: DISCONNECT_REASON
protected _disconnectWe: null | boolean
protected _pingTimeout: number
private _logger: Debugger
private DEBUG: boolean
/**
* Subprotocols (e.g. `ETH`) derived from the exchange on
* capabilities
*/
_protocols: ProtocolDescriptor[]
constructor(options: PeerOptions) {
this.events = new EventEmitter()
// hello data
this.clientId = options.clientId
this._capabilities = options.capabilities
this.common = options.common
this._port = options.port
this.id = options.id
this._remoteClientIdFilter = options.remoteClientIdFilter
// ECIES session
this._remoteId = options.remoteId
this._EIP8 = options.EIP8 ?? true
this._eciesSession = new ECIES(options.privateKey, this.id, this._remoteId, this.common)
// Auth, Ack, Header, Body
this._state = 'Auth'
this._weHello = null
this._hello = null
this._nextPacketSize = 307
// socket
this._socket = options.socket
this._socketData = new Uint8Array()
this._socket.on('data', this._onSocketData.bind(this))
this._socket.on('error', (err: Error) => this.events.emit('error', err))
this._socket.once('close', this._onSocketClose.bind(this))
this._logger =
this._socket.remoteAddress !== undefined
? devp2pDebug.extend(this._socket.remoteAddress).extend(DEBUG_BASE_NAME)
: devp2pDebug.extend(DEBUG_BASE_NAME)
this._connected = false
this._closed = false
this._disconnectWe = null
this._pingIntervalId = null
this._pingTimeout = options.timeout
this._pingTimeoutId = null
// sub-protocols
this._protocols = []
// send AUTH if outgoing connection
if (this._remoteId !== null) {
this._sendAuth()
}
this.DEBUG =
typeof window === 'undefined' ? (process?.env?.DEBUG?.includes('ethjs') ?? false) : false
}
/**
* Send AUTH message
*/
_sendAuth() {
if (this._closed) return
this.DEBUG &&
this._logger(
`Send auth (EIP8: ${this._EIP8}) to ${this._socket.remoteAddress}:${this._socket.remotePort}`,
)
if (this._EIP8 === true) {
const authEIP8 = this._eciesSession.createAuthEIP8()
if (!authEIP8) return
this._socket.write(authEIP8)
} else {
const authNonEIP8 = this._eciesSession.createAuthNonEIP8()
if (!authNonEIP8) return
this._socket.write(authNonEIP8)
}
this._state = 'Ack'
this._nextPacketSize = 210
}
/**
* Send ACK message
*/
_sendAck() {
if (this._closed) return
this.DEBUG &&
this._logger(
`Send ack (EIP8: ${this._eciesSession['_gotEIP8Auth']}) to ${this._socket.remoteAddress}:${this._socket.remotePort}`,
)
if (this._eciesSession['_gotEIP8Auth']) {
const ackEIP8 = this._eciesSession.createAckEIP8()
if (!ackEIP8) return
this._socket.write(ackEIP8)
} else {
const ackOld = this._eciesSession.createAckOld()
if (!ackOld) return
this._socket.write(ackOld)
}
this._state = 'Header'
this._nextPacketSize = 32
this._sendHello()
}
/**
* Create message HEADER and BODY and send to socket
* Also called from SubProtocol context
* @param code
* @param data
*/
_sendMessage(code: number, data: Uint8Array) {
if (this._closed) return false
const msg = concatBytes(RLP.encode(code), data)
const header = this._eciesSession.createBlockHeader(msg.length)
if (!header || this._socket.destroyed) return
this._socket.write(header)
const body = this._eciesSession.createBody(msg)
// this._socket.destroyed added here and above to safeguard against
// occasional "Cannot call write after a stream was destroyed" errors.
// Eventually this can be caught earlier down the line.
if (!body || this._socket.destroyed) return
this._socket.write(body)
return true
}
/**
* Send HELLO message
*/
_sendHello() {
if (this.DEBUG) {
this.debug(
'HELLO',
`Send HELLO to ${this._socket.remoteAddress}:${
this._socket.remotePort
} protocolVersion=${BASE_PROTOCOL_VERSION} capabilities=${(this._capabilities ?? [])
// Filter out snap because we can't yet provide snap endpoints to the peers
// TODO: Remove when we can also serve snap requests from other peers
.filter((c) => c.name !== 'snap')
.map((c) => `${c.name}${c.version}`)
.join(',')} clientId=${bytesToUtf8(this.clientId)}`,
)
}
const payload: HelloMsg = [
intToBytes(BASE_PROTOCOL_VERSION),
this.clientId,
this._capabilities!.map((c) => [utf8ToBytes(c.name), intToBytes(c.version)]),
this._port === null ? new Uint8Array(0) : intToBytes(this._port),
this.id,
]
if (!this._closed) {
if (
this._sendMessage(PREFIXES.HELLO, RLP.encode(payload as never as Uint8Array[])) === true
) {
this._weHello = payload
}
if (this._hello) {
this.events.emit('connect')
}
}
}
/**
* Send DISCONNECT message
* @param reason
*/
_sendDisconnect(reason: DISCONNECT_REASON) {
const reasonName = this.getDisconnectPrefix(reason)
if (this.DEBUG) {
this.debug(
'DISCONNECT',
`Send DISCONNECT to ${this._socket.remoteAddress}:${this._socket.remotePort} (reason: ${reasonName})`,
reasonName,
)
}
const data = RLP.encode(reason)
if (this._sendMessage(PREFIXES.DISCONNECT, data) !== true) return
this._disconnectReason = reason
this._disconnectWe = true
this._closed = true
setTimeout(() => this._socket.end(), 2000) // 2 sec * 1000
}
/**
* Send PING message
*/
_sendPing() {
if (this.DEBUG) {
this.debug('PING', `Send PING to ${this._socket.remoteAddress}:${this._socket.remotePort}`)
}
let data = RLP.encode([])
if (this._hello !== null && this._hello.protocolVersion >= 5) {
data = snappy.compress(data)
}
if (this._sendMessage(PREFIXES.PING, data) !== true) return
clearTimeout(this._pingTimeoutId!)
this._pingTimeoutId = setTimeout(() => {
this.disconnect(DISCONNECT_REASON.TIMEOUT)
}, this._pingTimeout)
}
/**
* Send PONG message
*/
_sendPong() {
if (this.DEBUG) {
this.debug('PONG', `Send PONG to ${this._socket.remoteAddress}:${this._socket.remotePort}`)
}
let data = RLP.encode([])
if (this._hello !== null && this._hello.protocolVersion >= 5) {
data = snappy.compress(data)
}
this._sendMessage(PREFIXES.PONG, data)
}
/**
* AUTH message received
*/
_handleAuth() {
const bytesCount = this._nextPacketSize
const parseData = this._socketData.subarray(0, bytesCount)
if (!this._eciesSession['_gotEIP8Auth']) {
if (parseData.subarray(0, 1) === hexToBytes('0x04')) {
this._eciesSession.parseAuthPlain(parseData)
} else {
this._eciesSession['_gotEIP8Auth'] = true
this._nextPacketSize = bytesToInt(this._socketData.subarray(0, 2)) + 2
return
}
} else {
this._eciesSession.parseAuthEIP8(parseData)
}
this._state = 'Header'
this._nextPacketSize = 32
process.nextTick(() => this._sendAck())
this._socketData = this._socketData.subarray(bytesCount)
}
/**
* ACK message received
*/
_handleAck() {
const bytesCount = this._nextPacketSize
const parseData = this._socketData.subarray(0, bytesCount)
if (!this._eciesSession['_gotEIP8Ack']) {
if (parseData.subarray(0, 1) === hexToBytes('0x04')) {
this._eciesSession.parseAckPlain(parseData)
this.DEBUG &&
this._logger(
`Received ack (old format) from ${this._socket.remoteAddress}:${this._socket.remotePort}`,
)
} else {
this._eciesSession['_gotEIP8Ack'] = true
this._nextPacketSize = bytesToInt(this._socketData.subarray(0, 2)) + 2
return
}
} else {
this._eciesSession.parseAckEIP8(parseData)
this.DEBUG &&
this._logger(
`Received ack (EIP8) from ${this._socket.remoteAddress}:${this._socket.remotePort}`,
)
}
this._state = 'Header'
this._nextPacketSize = 32
process.nextTick(() => this._sendHello())
this._socketData = this._socketData.subarray(bytesCount)
}
/**
* HELLO message received
*/
_handleHello(payload: any) {
this._hello = {
protocolVersion: bytesToInt(payload[0]),
clientId: bytesToUtf8(payload[1]),
capabilities: payload[2].map((item: any) => {
return { name: bytesToUtf8(item[0]), version: bytesToInt(item[1]) }
}),
port: bytesToInt(payload[3]),
id: payload[4],
}
if (this.DEBUG) {
this.debug(
'HELLO',
`Received HELLO ${this._socket.remoteAddress}:${this._socket.remotePort} protocolVersion=${
this._hello.protocolVersion
} capabilities=${(this._hello.capabilities ?? [])
.map((c) => `${c.name}${c.version}`)
.join(',')} clientId=${this._hello.clientId}`,
)
}
if (this._remoteId === null) {
this._remoteId = this._hello.id
} else if (!equalsBytes(this._remoteId, this._hello.id)) {
return this.disconnect(DISCONNECT_REASON.INVALID_IDENTITY)
}
if (this._remoteClientIdFilter !== undefined) {
for (const filterStr of this._remoteClientIdFilter) {
if (this._hello.clientId.toLowerCase().includes(filterStr.toLowerCase())) {
return this.disconnect(DISCONNECT_REASON.USELESS_PEER)
}
}
}
const shared: { [name: string]: Capabilities } = {}
for (const item of this._hello.capabilities) {
for (const c of this._capabilities!) {
if (c.name !== item.name || c.version !== item.version) continue
if (shared[c.name] !== undefined && shared[c.name].version > c.version) continue
shared[c.name] = c
}
}
let offset = BASE_PROTOCOL_LENGTH
this._protocols = Object.keys(shared)
.map((key) => shared[key])
.sort((obj1, obj2) => (obj1.name < obj2.name ? -1 : 1))
.map((obj) => {
const _offset = offset
offset += obj.length
// The send method handed over to the subprotocol object (e.g. an `ETH` instance).
// The subprotocol is then calling into the lower level method
// (e.g. `ETH` calling into `Peer._sendMessage()`).
const sendMethod = (code: number, data: Uint8Array) => {
if (code > obj.length) throw EthereumJSErrorWithoutCode('Code out of range')
this._sendMessage(_offset + code, data)
}
// Dynamically instantiate the subprotocol object
// from the constructor
const SubProtocol = obj.constructor
const protocol = new SubProtocol(obj.version, this, sendMethod)
return { protocol, offset: _offset, length: obj.length }
})
if (this._protocols.length === 0) {
return this.disconnect(DISCONNECT_REASON.USELESS_PEER)
}
this._connected = true
this._pingIntervalId = setInterval(() => this._sendPing(), PING_INTERVAL)
if (this._weHello) {
this.events.emit('connect')
}
}
/**
* DISCONNECT message received
* @param payload
*/
_handleDisconnect(payload: any) {
this._closed = true
// When `payload` is from rlpx it is `Uint8Array` and when from subprotocol it is `[Uint8Array]`
this._disconnectReason = (
payload instanceof Uint8Array
? bytesToInt(payload)
: bytesToInt(payload[0] ?? Uint8Array.from([0]))
) as DISCONNECT_REASON
if (this.DEBUG) {
this.debug(
'DISCONNECT',
`DISCONNECT reason: ${DisconnectReasonNames[this._disconnectReason!]} ${
this._socket.remoteAddress
}:${this._socket.remotePort}`,
DisconnectReasonNames[this._disconnectReason!],
)
}
this._disconnectWe = false
this._socket.end()
}
/**
* PING message received
*/
_handlePing() {
this._sendPong()
}
/**
* PONG message received
*/
_handlePong() {
clearTimeout(this._pingTimeoutId!)
}
/**
* Message handling, called from a SubProtocol context
* @param code
* @param msg
*/
_handleMessage(code: PREFIXES, msg: Uint8Array) {
switch (code) {
case PREFIXES.HELLO:
this._handleHello(msg)
break
case PREFIXES.DISCONNECT:
this._handleDisconnect(msg)
break
case PREFIXES.PING:
this._handlePing()
break
case PREFIXES.PONG:
this._handlePong()
break
}
}
/**
* Handle message header
*/
_handleHeader() {
const bytesCount = this._nextPacketSize
const parseData = this._socketData.subarray(0, bytesCount)
this.DEBUG &&
this._logger(`Received header ${this._socket.remoteAddress}:${this._socket.remotePort}`)
const size = this._eciesSession.parseHeader(parseData)
if (size === undefined) {
this.DEBUG && this._logger('invalid header size!')
return
}
this._state = 'Body'
this._nextPacketSize = size + 16
if (size % 16 > 0) this._nextPacketSize += 16 - (size % 16)
this._socketData = this._socketData.subarray(bytesCount)
}
/**
* Handle message body
*/
_handleBody() {
const bytesCount = this._nextPacketSize
const parseData = this._socketData.subarray(0, bytesCount)
const body = this._eciesSession.parseBody(parseData)
if (!body) {
this.DEBUG && this._logger('empty body!')
return
}
this.DEBUG &&
this._logger(
`Received body ${this._socket.remoteAddress}:${this._socket.remotePort} ${formatLogData(
bytesToHex(body),
verbose,
)}`,
)
this._state = 'Header'
this._nextPacketSize = 32
// RLP hack
let code = body[0]
if (code === 0x80) code = 0
if (code !== PREFIXES.HELLO && code !== PREFIXES.DISCONNECT && this._hello === null) {
return this.disconnect(DISCONNECT_REASON.PROTOCOL_ERROR)
}
// Protocol object referencing either this Peer object or the
// underlying subprotocol (e.g. `ETH`)
const protocolObj = this._getProtocol(code)
if (protocolObj === undefined) return this.disconnect(DISCONNECT_REASON.PROTOCOL_ERROR)
const msgCode = (code - protocolObj.offset) as PREFIXES
const protocolName = protocolObj.protocol.constructor.name
const postAdd = `(code: ${code} - ${protocolObj.offset} = ${msgCode}) ${this._socket.remoteAddress}:${this._socket.remotePort}`
if (protocolName === 'Peer') {
const messageName = this.getMsgPrefix(msgCode)
if (this.DEBUG) {
this.debug(messageName, `Received ${messageName} message ${postAdd}`)
}
} else {
this.DEBUG && this._logger(`Received ${protocolName} subprotocol message ${postAdd}`)
}
try {
let payload: any = body.subarray(1)
// Use snappy uncompression if peer supports DevP2P >=v5
let compressed = false
const origPayload = payload
if (this._hello !== null && this._hello.protocolVersion >= 5) {
payload = snappy.uncompress(payload)
compressed = true
}
// Hotfix, 2021-09-21
// For a DISCONNECT message received it is often hard to
// decide if received within or outside the scope of the
// protocol handshake (both can happen).
//
// This lead to problems with unjustifiedly applying
// the snappy compression which subsequently breaks the
// RLP decoding.
//
// This is fixed by this hotfix by re-trying with the
// respective compressed/non-compressed payload.
//
// Note: there might be a cleaner solution to apply here.
//
if (protocolName === 'Peer') {
try {
payload = RLP.decode(payload)
} catch (e: any) {
if (msgCode === PREFIXES.DISCONNECT) {
if (compressed) {
payload = RLP.decode(origPayload)
} else {
payload = RLP.decode(snappy.uncompress(payload))
}
} else {
throw EthereumJSErrorWithoutCode(e)
}
}
}
protocolObj.protocol._handleMessage?.(msgCode, payload)
} catch (err: any) {
this.disconnect(DISCONNECT_REASON.SUBPROTOCOL_ERROR)
this.DEBUG && this._logger(`Error on peer subprotocol message handling: ${err}`)
this.events.emit('error', err)
}
this._socketData = this._socketData.subarray(bytesCount)
}
/**
* Process socket data
* @param data
*/
_onSocketData(data: Uint8Array) {
if (this._closed) return
this._socketData = concatBytes(this._socketData, data)
try {
while (this._socketData.length >= this._nextPacketSize) {
switch (this._state) {
case 'Auth':
this._handleAuth()
break
case 'Ack':
this._handleAck()
break
case 'Header':
this._handleHeader()
break
case 'Body':
this._handleBody()
break
}
}
} catch (err: any) {
this.disconnect(DISCONNECT_REASON.SUBPROTOCOL_ERROR)
this.DEBUG && this._logger(`Error on peer socket data handling: ${err}`)
this.events.emit('error', err)
}
}
/**
* React to socket being closed
*/
_onSocketClose() {
clearInterval(this._pingIntervalId!)
clearTimeout(this._pingTimeoutId!)
this._closed = true
if (this._connected) this.events.emit('close', this._disconnectReason, this._disconnectWe)
}
/**
* Returns either a protocol object with a `protocol` parameter
* reference to this Peer instance or to a subprotocol instance (e.g. `ETH`)
* (depending on the `code` provided)
*/
_getProtocol(code: number): ProtocolDescriptor | undefined {
if (code < BASE_PROTOCOL_LENGTH) return { protocol: this as unknown as Protocol, offset: 0 }
for (const obj of this._protocols) {
if (code >= obj.offset && code < obj.offset + obj.length!) return obj
}
}
getId() {
if (this._remoteId === null) return null
return this._remoteId
}
getHelloMessage() {
return this._hello
}
getProtocols(): Protocol[] {
return this._protocols.map((obj) => obj.protocol)
}
getMsgPrefix(code: PREFIXES): string {
return PrefixesNames[code]
}
getDisconnectPrefix(code: DISCONNECT_REASON): string {
return DisconnectReasonNames[code]
}
disconnect(reason: DISCONNECT_REASON = DISCONNECT_REASON.DISCONNECT_REQUESTED) {
this._sendDisconnect(reason)
}
/**
* Called once from the subprotocol (e.g. `ETH`) on the peer
* where a first successful `STATUS` msg exchange could be achieved.
*
* Can be used together with the `devp2p:FIRST_PEER` debugger.
*/
_addFirstPeerDebugger() {
const ip = this._socket.remoteAddress
if (typeof ip === 'string') {
this._logger = devp2pDebug.extend(ip).extend(`FIRST_PEER`).extend(DEBUG_BASE_NAME)
}
}
/**
* Debug message both on the generic as well as the
* per-message debug logger
* @param messageName Capitalized message name (e.g. `HELLO`)
* @param msg Message text to debug
* @param disconnectReason Capitalized disconnect reason (e.g. 'TIMEOUT')
*/
private debug(messageName: string, msg: string, disconnectReason?: string) {
if (disconnectReason !== undefined) {
this._logger.extend(messageName).extend(disconnectReason)(msg)
} else {
this._logger.extend(messageName)(msg)
}
}
}