UNPKG

@ethereumjs/devp2p

Version:
598 lines 22.6 kB
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.js"; import { devp2pDebug, formatLogData } from "../util.js"; import { ECIES } from "./ecies.js"; 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 const PREFIXES = { HELLO: 0x00, DISCONNECT: 0x01, PING: 0x02, PONG: 0x03, }; // Reverse mapping: numeric value -> key name export const PrefixesNames = Object.entries(PREFIXES).reduce((acc, [key, value]) => { acc[value] = key; return acc; }, {}); export class Peer { constructor(options) { 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) => 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, data) { 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 = [ 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)) === true) { this._weHello = payload; } if (this._hello) { this.events.emit('connect'); } } } /** * Send DISCONNECT message * @param reason */ _sendDisconnect(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) { this._hello = { protocolVersion: bytesToInt(payload[0]), clientId: bytesToUtf8(payload[1]), capabilities: payload[2].map((item) => { 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 = {}; 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, data) => { 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) { 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]))); 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, msg) { 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); 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 = 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) { 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) { 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) { 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) { 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) { if (code < BASE_PROTOCOL_LENGTH) return { protocol: this, 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() { return this._protocols.map((obj) => obj.protocol); } getMsgPrefix(code) { return PrefixesNames[code]; } getDisconnectPrefix(code) { return DisconnectReasonNames[code]; } 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') */ debug(messageName, msg, disconnectReason) { if (disconnectReason !== undefined) { this._logger.extend(messageName).extend(disconnectReason)(msg); } else { this._logger.extend(messageName)(msg); } } } //# sourceMappingURL=peer.js.map