Protocol version v5.1
Welcome to the Node Discovery Protocol v5 specification!
Note that this specification is a work in progress and may change incompatibly without prior notice.
Node Discovery is a system for finding other participants in a peer-to-peer network. The system can be used by any node, for any purpose, at no cost other than running the network protocol and storing a limited number of other nodes’ records. Any node can be used as an entry point into the network.
The system’s design is loosely inspired by the Kademlia DHT, but unlike most DHTs no arbitrary keys and values are stored. Instead, the DHT stores and relays ‘node records’, which are signed documents providing information about nodes in the network. Node Discovery acts as a database of live nodes in the network and performs two basic functions:
- Sampling the set of live participants: by walking the DHT, the network can be enumerated.
- Authoritative resolution of node records: if a node’s ID is known, the most recent version of its record can be retrieved.
Node Discovery v5 also supports topic-based service discovery through TopDisc. TopDisc is an extension layered on top of the ordinary node discovery network. It allows nodes to advertise participation in a topic or service, and allows other nodes to discover those advertisements while reusing the existing node table, ENR mechanism, packet format, and authenticated session machinery.
TopDisc is intended for discovering participants in higher-level services or overlays without requiring each service to operate a separate discovery network. Nodes that support TopDisc advertise this capability in their ENR. Nodes that do not support TopDisc remain ordinary Node Discovery v5 participants and continue to contribute to the global discovery network.
Nodes, Records and Distances
A participant in the Node Discovery Protocol is represented by a ‘node record’ as defined in [EIP-778].
The node record keeps arbitrary information about the node. For the purposes of this protocol, the node
must at least provide an IP address ("ip" or "ip6" key) and UDP port ("udp" key) in order to have
it’s record relayed in the DHT.
Node records are signed according to an ‘identity scheme’. Any scheme can be used with Node Discovery Protocol, and nodes using different schemes can communicate. The identity scheme of a node record defines how a 32-byte ‘node ID’ is derived from the information contained in the record.
The ‘distance’ between two node IDs is the bitwise XOR of the IDs, taken as the big-endian number.
distance(n₁, n₂) = n₁ XOR n₂In many situations, the logarithmic distance (i.e. length of differing suffix in bits) is used in place of the actual distance.
logdistance(n₁, n₂) = log2(distance(n₁, n₂))Maintaining The Local Node Record
Participants should update their record, increase the sequence number and sign a new version of the record whenever their information changes. This is especially important for changes to the node’s IP address and port.
Implementations should determine the external endpoint (the Internet-facing IP address and port on which the node can be reached) and include it in their record. If communication flows through a NAT device, the UPnP/NAT-PMP protocols or the mirrored UDP envelope IP and port found in the [PONG] message can be used to determine the external IP address and port. If the endpoint cannot be determined (e.g. when the NAT doesn’t support ‘full-cone’ translation), implementations should omit IP address and UDP port from the record.
Node Table
Nodes keep information about other nodes in their neighborhood. Neighbor nodes are stored in a routing table
consisting of ‘k-buckets’. For each 0 ≤ i < 256, every node keeps a k-bucket for nodes of
logdistance(self, n) == i. The Node Discovery Protocol uses k = 16, i.e. every k-bucket contains up to
16 node entries. The entries are sorted by time last seen — least-recently seen node at the head,
most-recently seen at the tail.
Whenever a new node N₁ is encountered, it can be inserted into the corresponding bucket. If the bucket contains
less than k entries N₁ can simply be added as the first entry. If the bucket already contains k entries, the
liveness of the least recently seen node in the bucket, N₂, needs to be revalidated. If no reply is received from
N₂ it is considered dead, removed and N₁ added to the front of the bucket.
Neighbors of very low distance are unlikely to occur in practice. Implementations may omit k-buckets for low distances.
Table Maintenance In Practice
Nodes are expected to keep track of their close neighbors and regularly refresh their information. To do so, a lookup targeting the least recently refreshed bucket should be performed at regular intervals.
Checking node liveness whenever a node is to be added to a bucket is impractical and creates a DoS vector. Implementations should perform liveness checks asynchronously with bucket addition and occasionally verify that a random node in a random bucket is live by sending [PING]. When the PONG response indicates that a new version of the node record is available, the liveness check should pull the new record and update it in the local table. If a node’s liveness has been verified many times, implementations may consider occasional non-responsiveness permissible and assume the node is live.
When responding to FINDNODE, implementations must avoid relaying any nodes whose liveness has not been verified. This is easy to achieve by storing an additional flag per node in the table, tracking whether the node has ever successfully responded to a PING request.
In order to keep all k-bucket positions occupied even when bucket members fail liveness checks, it is strongly recommended to maintain a ‘replacement cache’ alongside each bucket. This cache holds recently-seen nodes which would fall into the corresponding bucket but cannot become a member of the bucket because it is already at capacity. Once a bucket member becomes unresponsive, a replacement can be chosen from the cache.
Lookup
A ‘lookup’ locates the k closest nodes to a node ID. The lookup initiator starts by picking α closest nodes
to the target it knows of from the local table. The initiator then sends FINDNODE requests to those nodes.
α is an implementation-defined concurrency parameter, typically 3. As NODES responses are received, the
initiator resends FINDNODE to nodes it has learned about from previous queries. Of the k nodes the initiator
has heard of closest to the target, it picks α that it has not yet queried and sends FINDNODE to them. The
lookup terminates when the initiator has queried and gotten responses from the k closest nodes it has seen.
To improve the resilience of lookups against adversarial nodes, the algorithm may be adapted to perform network traversal on multiple disjoint paths. Not only does this approach benefit security, it also improves effectiveness because more nodes are visited during a single lookup.
The initial k closest nodes are partitioned into multiple independent ‘path’ buckets, and concurrent FINDNODE
requests executed as described above, with one difference: results discovered on one path are not reused on another,
i.e. each path attempts to reach the closest nodes to the lookup target independently without reusing intermediate
results found on another path. Note that it is still necessary to track previously asked nodes across all paths to
keep the paths disjoint.
Lookup Protocol
This section shows how the wire protocol messages can be used to perform a lookup interaction against a single node.
Node A is looking for target x. It selects node B from the local table or intermediate lookup results.
To query for nodes close to x on B, node A computes the query distance d = logdistance(B, x) and sends
its request.
A -> B FINDNODE [d]Node B responds with multiple nodes messages containing the nodes at the queried distance.
A <- B NODES [N₁, N₂, N₃]
A <- B NODES [N₄, N₅]Depending on the value of d and the content of Bs table, the response to the initial query might contain very
few nodes or no nodes at all. Should this be the case, A varies the distance to retrieve more nodes from adjacent
k-buckets on B:
A -> B FINDNODE [d+1]B responds with more nodes:
A <- B NODES [N₆, N₇]Node A now sorts all received nodes by distance to the lookup target and proceeds by repeating the lookup
procedure on another, closer node.
TopDisc and ordinary node discovery
The TopDisc system builds on the basic routing of discv5. A TopDisc-capable node first joins the network using FINDNODE and populates its local node table, learning about TopDisc capable nodes. These nodes are then used to bootstrap service tables (see below). As registration and lookup operations proceed, TopDisc-capable nodes can also return additional nodes to improve the service tables, as described below.
A TopDisc failure does not by itself imply an ordinary Discovery v5 failure. A node may be usable for ordinary node discovery but unusable for registration or lookup. Conversely, a node that fails ordinary Discovery v5 liveness checks ceases to be eligible for insertion into service tables.
ENR Capability
A node indicates support for service discovery messages by including the ng ENR entry.
ng = <version>The value of ng is an unsigned integer identifying the supported TopDisc protocol
version. Nodes whose ENR does not contain ng, or whose ng value is not supported by
the local implementation are not inserted into service tables and are not selected for
registration or lookup requests.
Services and Service Identifiers
TopDisc operates on 32-byte service identifiers. A service identifier denotes a higher-level service, subnetwork, overlay, or protocol-specific discovery target.
Service identifiers are in the same 256-bit identifier space as Node IDs. This allows TopDisc to apply the Node Discovery v5 XOR distance function between service identifiers and node IDs.
The mapping from higher-level service names or application-specific parameters to service identifiers is defined by the relevant service binding. Such parameters MAY include, for example, protocol name, network name, fork identifier, client capability, subnet identifier, or other service-specific values.
This document does not define a canonical derivation rule for service identifiers. The application can choose its identifiers freely.
Node Roles
A node that advertises ng capability in its ENR acts as a registrar, subject to local policy
and resource limits.
A registrar accepts registration and lookup requests, admits advertisements into its local ad cache, and returns admitted advertisements to discoverers.
A TopDisc-capable node may also act as an advertiser, a discoverer, or both.
An advertiser participates in a service and registers advertisements for that service with registrars. A node acts as an advertiser only for services that it chooses to advertise.
A discoverer queries registrars to obtain advertisements for a target service. A node acts as a discoverer only when it is looking up peers for a service.
The roles are not mutually exclusive. A single node can simultaneously act as a registrar, advertise one or more services, and discover peers for one or more services.
Service Tables
A service table B(s) is a per-service node table centred on service identifier s, rather than on
the local node ID.
Similar to the ordinary Node Table, B(s) is divided into distance buckets. The difference is the
reference point used to assign nodes to buckets. In the ordinary node table, a node n is placed
according to logdistance(self, n), where self is the local node ID. In a service table, the same
node n is placed according to logdistance(s, n), where s is the service identifier.
For each 0 ≤ i < 256, bucket bᵢ(s) contains TopDisc-capable nodes whose node IDs are at
logarithmic distance i from the service identifier:
bᵢ(s) = { n | logdistance(s, n) = i }Thus, B(s) gives the local node a service-centred view of the discovery network. Buckets closer to
s contain nodes whose IDs are closer to the service identifier, while buckets farther from s
contain nodes from progressively larger regions of the key space.
A node may maintain a service table for each service identifier for which it performs TopDisc
operations. Advertisers use B(s) as an advertise table for service s; discoverers use B(s) as
a search table for service s.
The registrar ad cache is separate from service tables. A registrar does not need to maintain B(s)
for every service represented in its ad cache.
Bootstrap from Ordinary Node Discovery
A node does not start advertisement placement or lookup from an empty service table.
The node first joins the Node Discovery v5 network using the standard bootstrapping procedure. It
populates its ordinary local node table through the existing PING, PONG, FINDNODE, lookup,
refresh, and liveness mechanisms.
For a service identifier s, the initial service table
B(s) = { b₀(s), b₁(s), ..., b₂₅₅(s) }is derived from the ordinary node table. When constructing this initial service table, implementations should:
- take nodes currently known in the ordinary node table;
- discard nodes whose ENR does not advertise a supported TopDisc version;
- discard nodes whose liveness has not been verified by the ordinary Node Discovery v5 table-maintenance rules;
- discard nodes currently excluded by local TopDisc usability policy;
- insert each remaining node into the corresponding bucket of
B(s).
The resulting B(s) is soft state. It need not be complete before operations begin. If the
ordinary node table contains too few nodes that can be used for operations, implementations
should continue ordinary Node Discovery v5 refresh and lookup operations until more candidates are
learned.
Ongoing Maintenance of B(s)
A service table is maintained from two sources:
- the ordinary Discovery v5 node table; and
- auxiliary ENRs learned through NODES delivered as a response to TOPICQUERY.
When a newly verified node advertising TopDisc capability is learned through ordinary discovery, it becomes eligible for insertion into relevant service tables.
Responses to registration and lookup requests may include auxiliary ENRs selected from the
responder’s view of the service table. Such ENRs may be inserted into B(s) only after ENR
validation, capability checking, and any local usability checks.
Over time, this causes B(s) to become better aligned with the service identifier than the ordinary
local node table, while still remaining anchored in ordinary Discovery v5 state.
Notation
Here we present the notation that is used throughout this document.
[ .. , .. , .. ]
is recursive encoding as an RLP list
a || b
means binary concatenation of a and b
xor(a, b)
means binary XOR of a and b
sha256(x)
is the SHA256 digest of x
aesctr_encrypt(key, iv, pt)
is unauthenticated AES/CTR symmetric encryption with the given key and iv.
Size of key and iv is 16 bytes (AES-128).
aesgcm_encrypt(key, nonce, pt, ad)
is AES-GCM encryption/authentication with the given key, nonce and additional
authenticated data ad. Size of key is 16 bytes (AES-128), size of nonce 12 bytes.
UDP Communication
Node discovery messages are sent as UDP datagrams. Since UDP is a lossy transport, packets may be received in any order or not at all. Implementations should not re-send packets if the recipient doesn’t respond.
The maximum size of any packet is 1280 bytes. Implementations should not generate or process packets larger than this size. Most messages are smaller than this limit by definition, the exception being the NODES message. FINDNODE returns up to 16 records, plus other data, and TOPICQUERY may also distribute a significantly long list of ENRs. As per specification the maximum size of an ENR is 300 bytes. A NODES message containing all FINDNODE response records would be at least 4800 bytes, not including additional data such as the header. To stay below the size limit, NODES responses are sent as multiple messages and specify the total number of responses in the message.
The minimum size of any Discovery v5 packet is 63 bytes. Implementations should reject packets smaller than this size.
Since low-latency communication is expected, implementations should place short timeouts on request/response interactions. Good timeout values are 500ms for a single request/response and 1s for the handshake.
When responding to a request, the response should be sent to the UDP envelope address of the request.
Packet Encoding
The protocol deals with three distinct kinds of packets:
- Ordinary message packets, which carry an encrypted/authenticated message.
- WHOAREYOU packets, which are sent when the recipient of an ordinary message packet cannot decrypt/authenticate the packet’s message.
- Handshake message packets, which are sent following WHOAREYOU. These packets establish a new session and carry handshake-related data in addition to the encrypted/authenticated message.
In the following definitions, we assume that the sender of a packet has knowledge of its
own 256-bit node ID (src-id) and the node ID of the packet destination (dest-id). When
sending any packet except WHOAREYOU, the sender also generates a unique 96-bit nonce
value.
Protocol Header
All discovery packets contain a header followed by an optional encrypted and authenticated message.
Header information is ‘masked’ using symmetric encryption in order to avoid static identification of the protocol by firewalls.
packet = masking-iv || masked-header || message
masked-header = aesctr_encrypt(masking-key, masking-iv, header)
masking-key = dest-id[:16]
masking-iv = uint128 -- random data unique to packetThe masked-header contains the actual packet header, which starts with a fixed-size
static-header, followed by a variable-length authdata section (of size authdata-size).
header = static-header || authdata
static-header = protocol-id || version || flag || nonce || authdata-size
protocol-id = "discv5"
version = 0x0001
authdata-size = uint16 -- byte length of authdata
flag = uint8 -- packet type identifier
nonce = uint96 -- nonce of messageDecrypting the masked header data works as follows: The recipient constructs an AES/CTR
stream cipher using its own node ID (dest-id) as the key and taking the IV from the
packet. It can then decrypt the static-header and verify that protocol-id matches the
expected string. If it does, the recipient can read authdata-size and unmask the
remaining authdata.
Implementations should not respond to packets with mismatching protocol-id.
In ordinary message packets and handshake message packets, the packet contains an
authenticated message after the authdata section. For WHOAREYOU packets, the message
is empty. Implementations must generate a unique nonce value for every message packet.
message = aesgcm_encrypt(initiator-key, nonce, message-pt, message-ad)
message-pt = message-type || message-data
message-ad = masking-iv || headerThe flag field of the header identifies the kind of packet and determines the encoding
of authdata, which differs depending on the packet type.
Ordinary Message Packet (flag = 0)
For message packets, the authdata section is just the source node ID.
authdata = src-id
authdata-size = 32
WHOAREYOU Packet (flag = 1)
In WHOAREYOU packets, the authdata section contains information for the identity
verification procedure. The message part of WHOAREYOU packets is always empty. The
nonce part of the packet must be set to the nonce of the message packet that caused
the WHOAREYOU response.
authdata = id-nonce || enr-seq
authdata-size = 24
id-nonce = uint128 -- random bytes
enr-seq = uint64 -- ENR sequence number of the requesting node
Handshake Message Packet (flag = 2)
For handshake message packets, the authdata section has variable size since public key
and signature sizes depend on the ENR identity scheme. For the “v4” identity scheme, we
assume 64-byte signature size and 33 bytes of (compressed) public key size.
authdata starts with a fixed-size authdata-head component, followed by the ID
signature, ephemeral public key and optional node record.
The record field may be omitted if the enr-seq of WHOAREYOU is recent enough, i.e.
when it matches the current sequence number of the sending node. If enr-seq is zero, the
record must be sent. Node records are encoded and verified as specified in EIP-778.
Please refer to the handshake section for more information about the content of the handshake packet.
authdata = authdata-head || id-signature || eph-pubkey || record
authdata-head = src-id || sig-size || eph-key-size
authdata-size = 34 + sig-size + eph-key-size + len(record)
sig-size = uint8 -- value: 64 for ID scheme "v4"
eph-key-size = uint8 -- value: 33 for ID scheme "v4"
Protocol Messages
This section lists all defined messages which can be sent and received. The hexadecimal
value in parentheses is the message-type.
The first element of every message-data list is the request ID. request-id is an RLP
byte array of length <= 8 bytes. For requests, this value is assigned by the requester.
The recipient of a message must mirror the value in the request-id element of the
response. The selection of appropriate values for request IDs is left to the implementation.
PING Request (0x01)
message-data = [request-id, enr-seq]
message-type = 0x01
enr-seq = local ENR sequence number of senderPING checks whether the recipient is alive and informs it about the sender’s ENR sequence number.
PONG Response (0x02)
message-data = [request-id, enr-seq, recipient-ip, recipient-port]
message-type = 0x02
enr-seq = ENR sequence number of sender
recipient-ip = 16 or 4 byte IP address of the intended recipient
recipient-port = recipient UDP port, a 16-bit integerPONG is the reply to PING.
FINDNODE Request (0x03)
message-data = [request-id, [distance₁, distance₂, ..., distanceₙ]]
message-type = 0x03
distanceₙ = requested log2 distance, a positive integerFINDNODE queries for nodes at the given logarithmic distances from the recipient’s node
ID. When distance 0 is requested, the result set should contain the recipient’s current
record.
The recipient should create the result set by collecting nodes from its local node table according to the requested distances. Implementations should limit the number of nodes in the result set. The recommended result limit for FINDNODE queries is 16 nodes.
NODES Response (0x04)
message-data = [request-id, total, [ENR, ...]]
message-type = 0x04
total = total number of responses to the requestNODES is sent as a response to FINDNODE, REGTOPIC, or TOPICQUERY. Multiple NODES messages
may be sent as responses to a single query. Implementations may place a limit on the
allowed maximum for total. If exceeded, additional responses may be ignored.
When handling NODES as a response to FINDNODE, the recipient should verify that the received nodes match the requested distances.
When NODES appears as a response to REGTOPIC or TOPICQUERY, it carries auxiliary ENRs
selected from the responder’s view of the service table for the requested topic. These
ENRs are routing information for the requester to populate or refresh its own service
table B(s). They are not themselves topic registrants; the actual registered nodes are
returned via TOPICNODES.
TALKREQ Request (0x05)
message-data = [request-id, protocol, request]
message-type = 0x05TALKREQ sends an application-level request. The purpose of this message is pre-negotiating
connections made through another application-specific protocol identified by protocol.
protocol and request are RLP byte arrays.
The recipient must respond with a TALKRESP message containing the response to the request.
If the protocol is unknown to the recipient, it must respond with a TALKRESP response
containing empty response data.
TALKRESP Response (0x06)
message-data = [request-id, response]
message-type = 0x06
request-id = request-id of TALKREQTALKRESP is the response to TALKREQ. The response is a RLP byte array containing the
response data.
REGTOPIC Request (0x07)
message-data = [request-id, topic, ENR, ticket,
[topic-distance₁, topic-distance₂, ..., topic-distanceₙ]]
message-type = 0x07
topic = 32-byte service / topic identifier
ENR = current node record of sender
ticket = opaque byte array containing a ticket previously issued by the
recipient registrar; empty (`0x80`) on first attempt
topic-distanceₙ = positive integer log2 distance from `topic` where the sender's
service table `B(topic)` still has spaceREGTOPIC asks the recipient registrar to register the sender (identified by ENR) for
service topic. If the sender has a ticket from a previous registration attempt with this
registrar, it must present the ticket; otherwise ticket is the empty byte array.
The topic-distance list carries the sender’s “send me ENRs at these distances” hint to
the recipient: when returning auxiliary ENRs, the recipient should prefer ENRs whose log2
distance to topic matches one of the listed values, so the response helps the sender
populate its service table.
REGTOPIC is always answered with a single REGCONFIRMATION response. The recipient may additionally send zero or more NODES responses carrying auxiliary ENRs selected from its view of the service table.
See the theory section on tickets and theory section on registrar admission control for the registrar’s waiting-time semantics.
REGCONFIRMATION Response (0x08)
message-data = [request-id, total, ticket, wait-time]
message-type = 0x08
request-id = request-id of REGTOPIC
total = total number of responses (REGCONFIRMATION + NODES) to the request
ticket = ticket issued by the registrar for the next attempt;
empty byte array (RLP: `0x80`) when the registration was admitted
wait-time = milliseconds to wait before submitting the next REGTOPIC attempt
with the returned `ticket`. When `ticket` is empty, `wait-time` carries
the advertisement lifetime instead.REGCONFIRMATION is the response to REGTOPIC. It is sent immediately by the registrar and
plays two roles, distinguished by the length of ticket:
- If
ticketis the empty byte array, the advertisement has been admitted to the registrar’s ad cache.wait-timeindicates the advertisement lifetime; the advertiser should renew before that lifetime elapses to remain in the cache. - If
ticketis non-empty, the advertisement was not admitted on this attempt. The sender must wait at leastwait-timemilliseconds and re-attempt the registration with the returnedticket. See the theory section on tickets and the theory section on the waiting-time function.
The total field announces the total number of responses (this REGCONFIRMATION plus any
NODES messages carrying auxiliary ENRs) that the registrar will send for this request.
TOPICQUERY Request (0x09)
message-data = [request-id, topic,
[topic-distance₁, topic-distance₂, ..., topic-distanceₙ]]
message-type = 0x09
topic = 32-byte service / topic identifier
topic-distanceₙ = positive integer log2 distance from `topic` where the sender's
service table `B(topic)` still has spaceTOPICQUERY asks the recipient to return registered advertisers for the given topic from
its ad cache. The recipient sends zero or more TOPICNODES responses containing matching
advertiser ENRs, and may additionally send zero or more NODES responses carrying
auxiliary ENRs selected from its service-table view (see NODES).
The topic-distance list serves the same purpose as in REGTOPIC: it tells the recipient
which log2 distances from topic the sender’s service table still has room for, so the
recipient can choose useful auxiliary ENRs to include in its NODES responses.
See the theory section on lookup responses for the discoverer-side termination semantics
(distinct-advertisers count) and the theory section on parameters for Freturn.
TOPICNODES Response (0x0A)
message-data = [request-id, total, [ENR, ...]]
message-type = 0x0a
request-id = request-id of TOPICQUERY
total = total number of responses (NODES + TOPICNODES) to the requestTOPICNODES is the dedicated response to TOPICQUERY carrying advertiser ENRs that are currently registered for the requested topic in the recipient’s ad cache. Multiple TOPICNODES messages may be sent for a single TOPICQUERY.
The total field announces the total number of responses (TOPICNODES messages plus any
NODES messages carrying auxiliary ENRs) the recipient will send for this request.
Implementations may place a limit on the allowed maximum for total; if exceeded,
additional responses may be ignored.
The recipient should return only non-expired advertisements from its ad cache. When the
ad cache contains more than Freturn advertisements for the topic, the recipient
selects which advertisements to return; the exact selection policy is implementation
defined.
TOPICNODES carries only registered advertisers. Auxiliary routing information for the sender’s service table is carried separately via NODES responses.
Sessions
Discovery communication is encrypted and authenticated using session keys, established in the handshake. Since every node participating in the network acts as both client and server, a handshake can be initiated by either side of communication at any time.
Handshake Steps
Step 1: Node A sends message packet
In the following definitions, we assume that node A wishes to communicate with node B, e.g. to send a FINDNODE message. Node A must have a copy of node B’s record in order to communicate with it.
If node A has session keys from prior communication with B, it encrypts its request with those keys. If no keys are known, it initiates the handshake by sending an ordinary message packet with random message content.
A -> B FINDNODE message packet encrypted with unknown keyStep 2: Node B responds with challenge
Node B receives the message packet and extracts the source node ID from the packet header. If node B has session keys from prior communication with A, it attempts to decrypt the message data. If decryption and authentication of the message succeeds, there is no need for a handshake and node B can simply respond to the request.
If node B does not have session keys or decryption is not successful, it must initiate a handshake by
responding with a [WHOAREYOU packet]. It first generates a unique id-nonce value and includes it in the
packet. Node B also checks if it has a copy of node A’s record. If it does, it also includes the sequence
number of this record in the challenge packet, otherwise it sets the enr-seq field to zero.
Node B must also store the A’s record and the WHOAREYOU challenge for a short duration after sending it to node A because they will be needed again in step 4.
A <- B WHOAREYOU packet including id-nonce, enr-seqStep 3: Node A processes the challenge
Node A receives the challenge sent by node B, which confirms that node B is alive and is ready to perform
the handshake. The challenge can be traced back to the request packet which solicited it by checking the
nonce, which mirrors the request packet’s nonce.
Node A proceeds with the handshake by re-sending the FINDNODE request as a [handshake message packet].
This packet contains three parts in addition to the message: id-signature, ephemeral-pubkey and record.
The handshake uses the unmasked WHOAREYOU challenge as an input:
challenge-data = masking-iv || static-header || authdataNode A can now derive the new session keys. To do so, it first generates an ephemeral key pair on the
elliptic curve used by node B’s identity scheme. As an example, let’s assume the node record of B uses the
“v4” scheme. In this case the ephemeral-pubkey will be a public key on the secp256k1 curve.
ephemeral-key = random private key generated by node A
ephemeral-pubkey = public key corresponding to ephemeral-keyThe ephemeral key is used to perform Diffie-Hellman key agreement with node B’s static public key and the session keys are derived from it using the HKDF key derivation function.
dest-pubkey = public key corresponding to node B's static private key
secret = ecdh(dest-pubkey, ephemeral-key)
kdf-info = "discovery v5 key agreement" || node-id-A || node-id-B
prk = HKDF-Extract(challenge-data, secret)
key-data = HKDF-Expand(prk, kdf-info)
initiator-key = key-data[:16]
recipient-key = key-data[16:]Here HKDF-Extract(salt, IKM) and HKDF-Expand are the functions defined in RFC 5869
using HMAC-SHA-256. The salt is challenge-data and the input keying material is secret;
HKDF-Expand is invoked with kdf-info and produces 32 bytes of output.
Node A creates the id-signature, which proves that it controls the private key which
signed its node record. The signature also prevents replay of the handshake.
id-signature-text = "discovery v5 identity proof"
id-signature-input = id-signature-text || challenge-data || ephemeral-pubkey || node-id-B
id-signature = id_sign(sha256(id-signature-input))Finally, node A compares the enr-seq element of the WHOAREYOU challenge against its own node record
sequence number. If the sequence number in the challenge is lower, it includes its record into the handshake
message packet. The request is now re-sent, with the message encrypted using the new session keys.
A -> B FINDNODE handshake message packet, encrypted with new initiator-keyStep 4: Node B receives handshake message
When node B receives the handshake message packet, it first loads the node record and WHOAREYOU challenge which it sent and stored earlier. If node B did not have the node record of node A, the handshake message packet must contain a node record. A record may also be present if node A determined that its record is newer than B’s current copy. If the packet contains a node record, B must first validate it by checking the record’s signature.
Node B then verifies the id-signature against the identity public key of A’s record. After that, B can
perform the key derivation using its own static private key and the ephemeral-pubkey from the handshake
packet. Using the resulting session keys, it attempts to decrypt the message contained in the packet.
If the message can be decrypted and authenticated, Node B considers the new session keys valid and responds
to the message. In our example case, the response is a NODES message:
A <- B NODES encrypted with new recipient-keyStep 5: Node A receives response message
Node A receives the message packet response and authenticates/decrypts it with the new session keys. If decryption/authentication succeeds, node B’s identity is verified and node A also considers the new session keys valid.
Identity-Specific Cryptography in the Handshake
Establishment of session keys is dependent on the [identity scheme] used by the recipient (i.e. the node
which sends WHOAREYOU). Likewise, the signature over id-sig-input is made by the identity key of the
initiator. It is not required that initiator and recipient use the same identity scheme in their respective
node records. Implementations must be able to perform the handshake for all supported identity schemes.
At this time, the only supported identity scheme is “v4”. id_sign(hash) creates a signature over hash
using the node’s static private key. The signature is encoded as the 64-byte array r || s, i.e. as the
concatenation of the signature values. ecdh(pubkey, privkey) creates a secret through elliptic-curve
Diffie-Hellman key agreement. The public key is multiplied by the private key to create a secret ephemeral
key eph = pubkey * privkey. The 33-byte secret output is y || eph.x where y is 0x02 when eph.y
is even or 0x03 when eph.y is odd.
Handshake Implementation Considerations
Since a handshake may happen at any time, UDP packets may be reordered by transmitting networking equipment, implementations must deal with certain subtleties regarding the handshake.
In general, implementations should keep a reference to all sent request packets until the request either times out, is answered by the corresponding response packet or answered by WHOAREYOU. If WHOAREYOU is received as the answer to a request, the request must be re-sent as a handshake packet.
If an implementation supports sending concurrent requests, multiple responses may be pending when WHOAREYOU is received, as in the following example:
A -> B FINDNODE
A -> B PING
A -> B TOPICQUERY
A <- B WHOAREYOU (nonce references PING)When this happens, all buffered requests can be considered invalid (the remote end cannot decrypt them) and
the packet referenced by the WHOAREYOU nonce (in this example: PING) must be re-sent as a handshake. When
the response to the re-sent is received, the new session is established and other pending requests (example:
FINDNODE, TOPICQUERY) may be re-sent. Note that WHOAREYOU is only ever valid as a response to a previously
sent request. If WHOAREYOU is received but no requests are pending, the handshake attempt can be ignored.
Another important issue is the processing of message packets while a challenge is received: consider the case where node A has sent a packet that B cannot decrypt, and B has responded with WHOAREYOU.
A -> B FINDNODE
A <- B WHOAREYOUNode B is now waiting for a handshake message packet to complete the new session, but instead receives another ordinary message packet.
A -> B ORDINARY MESSAGE PACKETIn this case, implementations should respond with a new WHOAREYOU challenge referencing the message packet.
Session Cache
Nodes should store session keys for communication with other recently-seen nodes. Since sessions are ephemeral and can be re-established whenever necessary, it is sufficient to store a limited number of sessions in an in-memory LRU cache. To prevent IP spoofing attacks, implementations must ensure that session secrets and the handshake are tied to a specific UDP endpoint. This is simple to implement by using the node ID and IP/port as the ‘key’ into the in-memory session cache.
When a node switches endpoints, e.g. when roaming between different wireless networks, sessions will have to be re-established by handshaking again. This requires no effort on behalf of the roaming node because the recipients of protocol messages will simply refuse to decrypt messages from the new endpoint and reply with WHOAREYOU.
The number of messages which can be encrypted with a certain session key is limited because encryption of each message requires a unique nonce for AES-GCM. In addition to the keys, the session cache must also keep track of the count of outgoing messages to ensure the uniqueness of nonce values. Since the wire protocol uses 96 bit AES-GCM nonces, it is strongly recommended to generate them by encoding the current outgoing message count into the first 32 bits of the nonce and filling the remaining 64 bits with random data generated by a cryptographically secure random number generator.