Merge pull request #38 from YouJustDontKnow/v5-udp-transport

v5 UDP packet handling
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/AuthenticationProvider.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/AuthenticationProvider.kt
new file mode 100644
index 0000000..562c7e8
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/AuthenticationProvider.kt
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.misc.AuthHeader
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import org.apache.tuweni.devp2p.v5.misc.SessionKey
+
+/**
+ * Module for securing messages communications. It creates required parameters for peers handshake execution.
+ * All session keys information is located here, which are used for message encryption/decryption
+ */
+interface AuthenticationProvider {
+
+  /**
+   * Creates authentication header to initialize handshake process. As a result it creates an authentication
+   * header to include to udp message.
+   *
+   * @param handshakeParams parameters for authentication header creation
+   *
+   * @return authentication header for handshake initialization
+   */
+  fun authenticate(handshakeParams: HandshakeInitParameters): AuthHeader
+
+  /**
+   * Verifies, that incoming authentication header is valid via decoding authorization response and checking
+   * nonce signature. In case if everything is valid, it creates and stores session key
+   *
+   * @param senderNodeId sender node identifier
+   * @param authHeader authentication header for verification
+   */
+  fun finalizeHandshake(senderNodeId: Bytes, authHeader: AuthHeader)
+
+  /**
+   * Provides session key by node identifier
+   *
+   * @param nodeId node identifier
+   *
+   * @return session key for message encryption/decryption
+   */
+  fun findSessionKey(nodeId: String): SessionKey?
+
+  /**
+   * Persists session key by node identifier
+   *
+   * @param nodeId node identifier
+   * @param sessionKey session key
+   */
+  fun setSessionKey(nodeId: String, sessionKey: SessionKey)
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/MessageHandler.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/MessageHandler.kt
new file mode 100644
index 0000000..8448492
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/MessageHandler.kt
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import java.net.InetSocketAddress
+
+/**
+ * Udp message handler, aimed to process it's parameters and sending result
+ */
+interface MessageHandler<T : UdpMessage> {
+
+  /**
+   * @param message udp message containing parameters
+   * @param address sender address
+   * @param srcNodeId sender node identifier
+   * @param connector connector for response send if required
+   */
+  fun handle(message: T, address: InetSocketAddress, srcNodeId: Bytes, connector: UdpConnector)
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/NodeDiscoveryService.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/NodeDiscoveryService.kt
new file mode 100644
index 0000000..9a0fcc0
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/NodeDiscoveryService.kt
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.internal.DefaultUdpConnector
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.io.Base64URLSafe
+import java.net.InetSocketAddress
+import java.time.Instant
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Service executes network discovery, according to discv5 specification
+ * (https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md)
+ */
+interface NodeDiscoveryService {
+
+  /**
+   * Initializes node discovery
+   */
+  fun start()
+
+  /**
+   * Executes service shut down
+   */
+  fun terminate(await: Boolean = false)
+
+}
+
+internal class DefaultNodeDiscoveryService(
+  private val keyPair: SECP256K1.KeyPair,
+  private val localPort: Int,
+  private val bindAddress: InetSocketAddress = InetSocketAddress(localPort),
+  private val bootstrapENRList: List<String> = emptyList(),
+  private val enrSeq: Long = Instant.now().toEpochMilli(),
+  private val selfENR: Bytes = EthereumNodeRecord.toRLP(
+    keyPair,
+    enrSeq,
+    emptyMap(),
+    bindAddress.address,
+    null,
+    bindAddress.port
+  ),
+  private val connector: UdpConnector = DefaultUdpConnector(bindAddress, keyPair, selfENR),
+  override val coroutineContext: CoroutineContext = Dispatchers.Default
+) : NodeDiscoveryService, CoroutineScope {
+
+  override fun start() {
+    connector.start()
+    launch { bootstrap() }
+  }
+
+  override fun terminate(await: Boolean) {
+    runBlocking {
+      val job = async { connector.terminate() }
+      if (await) {
+        job.await()
+      }
+    }
+  }
+
+  private fun bootstrap() {
+    bootstrapENRList.forEach {
+      if (it.startsWith("enr:")) {
+        val encodedEnr = it.substringAfter("enr:")
+        val rlpENR = Base64URLSafe.decode(encodedEnr)
+        val enr = EthereumNodeRecord.fromRLP(rlpENR)
+
+        val randomMessage = RandomMessage()
+        val address = InetSocketAddress(enr.ip(), enr.udp())
+
+        connector.addPendingNodeId(address, rlpENR)
+        connector.send(address, randomMessage, rlpENR)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/PacketCodec.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/PacketCodec.kt
new file mode 100644
index 0000000..eec5073
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/PacketCodec.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.misc.DecodeResult
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+
+/**
+ * Message reader/writer. It encodes and decodes messages, structured like at schema below
+ *
+ * tag || auth_tag || message
+ *
+ * tag || auth_header || message
+ *
+ * magic || message
+ *
+ * It also responsible for encryption functionality, so handlers receives raw messages for processing
+ */
+interface PacketCodec {
+
+  /**
+   * Encodes message, encrypting it's body
+   *
+   * @param message message for encoding
+   * @param destNodeId receiver node identifier for tag creation
+   * @param handshakeParams optional handshake parameter, if it is required to initialize handshake
+   *
+   * @return encoded message
+   */
+  fun encode(message: UdpMessage, destNodeId: Bytes, handshakeParams: HandshakeInitParameters? = null): Bytes
+
+  /**
+   * Decodes message, decrypting it's body
+   *
+   * @param message message for decoding
+   *
+   * @return decoding result, including sender identifier and decoded message
+   */
+  fun decode(message: Bytes): DecodeResult
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/UdpConnector.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/UdpConnector.kt
new file mode 100644
index 0000000..9770885
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/UdpConnector.kt
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import java.net.InetSocketAddress
+
+/**
+ * Module, used for network communication. It accepts and sends incoming messages and also provides peer information,
+ * like node's ENR, key pair
+ */
+interface UdpConnector {
+
+  /**
+   * Bootstraps receive loop for incoming message handling
+   */
+  fun start()
+
+  /**
+   * Shut downs both udp receive loop and sender socket
+   */
+  fun terminate()
+
+  /**
+   * Sends udp message by socket address
+   *
+   * @param address receiver address
+   * @param message message to send
+   * @param destNodeId destination node identifier
+   * @param handshakeParams optional parameter to create handshake
+   */
+  fun send(
+    address: InetSocketAddress,
+    message: UdpMessage,
+    destNodeId: Bytes,
+    handshakeParams: HandshakeInitParameters? = null
+  )
+
+  /**
+   * Gives information about connector, whether receive channel is working
+   *
+   * @return availability information
+   */
+  fun available(): Boolean
+
+  /**
+   * Gives information about connector, whether receive loop is working
+   *
+   * @return availability information
+   */
+  fun started(): Boolean
+
+  /**
+   * Add node identifier which awaits for authentication
+   *
+   * @param address socket address
+   * @param nodeId node identifier
+   */
+  fun addPendingNodeId(address: InetSocketAddress, nodeId: Bytes)
+
+  /**
+   * Get node identifier which awaits for authentication
+   *
+   * @param address socket address
+   *
+   * @return node identifier
+   */
+  fun getPendingNodeIdByAddress(address: InetSocketAddress): Bytes
+
+  /**
+   * Provides node's key pair
+   *
+   * @return node's key pair
+   */
+  fun getNodeKeyPair(): SECP256K1.KeyPair
+
+  /**
+   * Provides node's ENR
+   *
+   * @return node's ENR
+   */
+  fun getEnr(): Bytes
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCM.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCM.kt
new file mode 100644
index 0000000..f1a22ae
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCM.kt
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.encrypt
+
+import org.apache.tuweni.bytes.Bytes
+import java.nio.ByteBuffer
+import javax.crypto.Cipher
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ * Util dedicated for AES-GCM encoding with key size equal 16 bytes
+ */
+object AES128GCM {
+
+  private const val ALGO_NAME: String = "AES"
+  private const val CIPHER_NAME: String = "AES/GCM/NoPadding"
+  private const val KEY_SIZE: Int = 128
+
+
+  /**
+   * AES128GCM encryption function
+   *
+   * @param key 16-byte encryption key
+   * @param nonce initialization vector
+   * @param message content for encryption
+   * @param data encryption metadata
+   */
+  fun encrypt(key: Bytes, nonce: Bytes, message: Bytes, data: Bytes): Bytes {
+    val nonceBytes = nonce.toArray()
+    val keySpec = SecretKeySpec(key.toArray(), ALGO_NAME)
+    val cipher = Cipher.getInstance(CIPHER_NAME)
+    val parameterSpec = GCMParameterSpec(KEY_SIZE, nonceBytes)
+
+    cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec)
+
+    cipher.updateAAD(data.toArray())
+
+    val encryptedText = Bytes.wrap(cipher.doFinal(message.toArray()))
+
+    val wrappedNonce = Bytes.wrap(nonceBytes)
+    val nonceSize = Bytes.ofUnsignedInt(nonceBytes.size.toLong())
+    return Bytes.wrap(nonceSize, wrappedNonce, encryptedText)
+  }
+
+  /**
+   * AES128GCM decryption function
+   *
+   * @param encryptedContent content for decryption
+   * @param key 16-byte encryption key
+   * @param data encryption metadata
+   */
+  fun decrypt(encryptedContent: Bytes, key: Bytes, data: Bytes): Bytes {
+    val buffer = ByteBuffer.wrap(encryptedContent.toArray())
+    val nonceLength = buffer.int
+    val nonce = ByteArray(nonceLength)
+    buffer.get(nonce)
+    val encryptedText = ByteArray(buffer.remaining())
+    buffer.get(encryptedText)
+
+    val keySpec = SecretKeySpec(key.toArray(), ALGO_NAME)
+
+    val parameterSpec = GCMParameterSpec(KEY_SIZE, nonce)
+    val cipher = Cipher.getInstance(CIPHER_NAME)
+    cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec)
+
+    cipher.updateAAD(data.toArray())
+
+    return Bytes.wrap(cipher.doFinal(encryptedText))
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGenerator.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGenerator.kt
new file mode 100644
index 0000000..cb21279
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGenerator.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.encrypt
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.misc.SessionKey
+import org.bouncycastle.crypto.digests.SHA256Digest
+import org.bouncycastle.crypto.generators.HKDFBytesGenerator
+import org.bouncycastle.crypto.params.HKDFParameters
+
+/**
+ * Generates session keys on handshake, using HKDF key derivation function
+ */
+object SessionKeyGenerator {
+
+  private const val DERIVED_KEY_SIZE: Int = 16
+
+  private val INFO_PREFIX = Bytes.wrap("discovery v5 key agreement".toByteArray())
+
+  /**
+   * Executes session keys generation
+   *
+   * @param srcNodeId sender node identifier
+   * @param destNodeId receiver node identifier
+   * @param secret the input keying material or seed
+   * @param idNonce nonce used as salt
+   */
+  fun generate(srcNodeId: Bytes, destNodeId: Bytes, secret: Bytes, idNonce: Bytes): SessionKey {
+    val info = Bytes.wrap(INFO_PREFIX, srcNodeId, destNodeId)
+
+    val hkdf = HKDFBytesGenerator(SHA256Digest())
+    val params = HKDFParameters(secret.toArray(), idNonce.toArray(), info.toArray())
+    hkdf.init(params)
+    return SessionKey(derive(hkdf), derive(hkdf), derive(hkdf))
+  }
+
+  private fun derive(hkdf: HKDFBytesGenerator): Bytes {
+    val result = ByteArray(DERIVED_KEY_SIZE)
+    hkdf.generateBytes(result, 0, result.size)
+    return Bytes.wrap(result)
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProvider.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProvider.kt
new file mode 100644
index 0000000..f03b63b
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProvider.kt
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import com.google.common.cache.Cache
+import com.google.common.cache.CacheBuilder
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.ENR_REQUEST_RETRY_DELAY_MS
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.AuthenticationProvider
+import org.apache.tuweni.devp2p.v5.encrypt.AES128GCM
+import org.apache.tuweni.devp2p.v5.encrypt.SessionKeyGenerator
+import org.apache.tuweni.devp2p.v5.misc.AuthHeader
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import org.apache.tuweni.devp2p.v5.misc.SessionKey
+import org.apache.tuweni.rlp.RLP
+import java.util.concurrent.TimeUnit
+
+class DefaultAuthenticationProvider(
+  private val keyPair: SECP256K1.KeyPair,
+  private val enr: Bytes
+) : AuthenticationProvider {
+
+  private val sessionKeys: Cache<String, SessionKey> = CacheBuilder
+    .newBuilder()
+    .expireAfterWrite(ENR_REQUEST_RETRY_DELAY_MS, TimeUnit.MILLISECONDS)
+    .build()
+  private val nodeId: Bytes = Hash.sha2_256(enr)
+
+  override fun authenticate(handshakeParams: HandshakeInitParameters): AuthHeader {
+    // Generate ephemeral key pair
+    val ephemeralKeyPair = SECP256K1.KeyPair.random()
+    val ephemeralKey = ephemeralKeyPair.secretKey()
+
+    val destEnr = EthereumNodeRecord.fromRLP(handshakeParams.destEnr)
+    val destNodeId = Hash.sha2_256(handshakeParams.destEnr)
+
+    // Perform agreement
+    val secret = SECP256K1.calculateKeyAgreement(ephemeralKey, destEnr.publicKey())
+
+    // Derive keys
+    val sessionKey = SessionKeyGenerator.generate(nodeId, destNodeId, secret, handshakeParams.idNonce)
+
+    setSessionKey(destNodeId.toHexString(), sessionKey)
+
+    val signature = sign(keyPair, handshakeParams)
+
+    return generateAuthHeader(enr, signature, handshakeParams, sessionKey.authRespKey, ephemeralKeyPair.publicKey())
+  }
+
+  override fun findSessionKey(nodeId: String): SessionKey? {
+    return sessionKeys.getIfPresent(nodeId)
+  }
+
+  override fun setSessionKey(nodeId: String, sessionKey: SessionKey) {
+    sessionKeys.put(nodeId, sessionKey)
+  }
+
+  override fun finalizeHandshake(senderNodeId: Bytes, authHeader: AuthHeader) {
+    val ephemeralPublicKey = SECP256K1.PublicKey.fromBytes(authHeader.ephemeralPublicKey)
+    val secret = SECP256K1.calculateKeyAgreement(keyPair.secretKey(), ephemeralPublicKey)
+
+    val sessionKey = SessionKeyGenerator.generate(senderNodeId, nodeId, secret, authHeader.idNonce)
+
+    val decryptedAuthResponse = AES128GCM.decrypt(authHeader.authResponse, sessionKey.authRespKey, Bytes.EMPTY)
+    RLP.decodeList(Bytes.wrap(decryptedAuthResponse)) { reader ->
+      reader.skipNext()
+      val signatureBytes = reader.readValue()
+      val enrRLP = reader.readValue()
+      val enr = EthereumNodeRecord.fromRLP(enrRLP)
+      val publicKey = enr.publicKey()
+      val signatureVerified = verifySignature(signatureBytes, authHeader.idNonce, publicKey)
+      if (!signatureVerified) {
+        throw IllegalArgumentException("Signature is not verified")
+      }
+      setSessionKey(senderNodeId.toHexString(), sessionKey)
+    }
+  }
+
+  private fun sign(keyPair: SECP256K1.KeyPair, params: HandshakeInitParameters): SECP256K1.Signature {
+    val signValue = Bytes.wrap(DISCOVERY_ID_NONCE, params.idNonce)
+    val hashedSignValue = Hash.sha2_256(signValue)
+    return SECP256K1.sign(hashedSignValue, keyPair)
+  }
+
+  private fun verifySignature(signatureBytes: Bytes, idNonce: Bytes, publicKey: SECP256K1.PublicKey): Boolean {
+    val signature = SECP256K1.Signature.fromBytes(signatureBytes)
+    val signValue = Bytes.wrap(DISCOVERY_ID_NONCE, idNonce)
+    val hashedSignValue = Hash.sha2_256(signValue)
+    return SECP256K1.verify(hashedSignValue, signature, publicKey)
+  }
+
+  private fun generateAuthHeader(
+    enr: Bytes,
+    signature: SECP256K1.Signature,
+    params: HandshakeInitParameters,
+    authRespKey: Bytes,
+    ephemeralPubKey: SECP256K1.PublicKey
+  ): AuthHeader {
+    val plain = RLP.encodeList { writer ->
+      writer.writeInt(VERSION)
+      writer.writeValue(signature.bytes())
+      writer.writeValue(enr) // TODO: Seq number if enrSeq from WHOAREYOU is equal to local, else nothing
+    }
+    val zeroNonce = Bytes.wrap(ByteArray(ZERO_NONCE_SIZE))
+    val authResponse = AES128GCM.encrypt(authRespKey, zeroNonce, plain, Bytes.EMPTY)
+
+    return AuthHeader(params.authTag, params.idNonce, ephemeralPubKey.bytes(), Bytes.wrap(authResponse))
+  }
+
+  companion object {
+    private const val ZERO_NONCE_SIZE: Int = 12
+    private const val VERSION: Int = 5
+
+    private val DISCOVERY_ID_NONCE: Bytes = Bytes.wrap("discovery-id-nonce".toByteArray())
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodec.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodec.kt
new file mode 100644
index 0000000..6dbab0f
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodec.kt
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.v5.AuthenticationProvider
+import org.apache.tuweni.devp2p.v5.PacketCodec
+import org.apache.tuweni.devp2p.v5.encrypt.AES128GCM
+import org.apache.tuweni.devp2p.v5.packet.FindNodeMessage
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import org.apache.tuweni.devp2p.v5.misc.AuthHeader
+import org.apache.tuweni.devp2p.v5.misc.DecodeResult
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import org.apache.tuweni.rlp.RLP
+import org.apache.tuweni.rlp.RLPReader
+import kotlin.IllegalArgumentException
+
+class DefaultPacketCodec(
+  private val keyPair: SECP256K1.KeyPair,
+  private val enr: Bytes,
+  private val nodeId: Bytes = Hash.sha2_256(enr),
+  private val authenticationProvider: AuthenticationProvider = DefaultAuthenticationProvider(keyPair, enr)
+) : PacketCodec {
+
+  override fun encode(message: UdpMessage, destNodeId: Bytes, handshakeParams: HandshakeInitParameters?): Bytes {
+    if (message is WhoAreYouMessage) {
+      val magic = UdpMessage.magic(nodeId)
+      val content = message.encode()
+      return Bytes.wrap(magic, content)
+    }
+
+    val tag = UdpMessage.tag(nodeId, destNodeId)
+    if (message is RandomMessage) {
+      val authTag = UdpMessage.authTag()
+      val rlpAuthTag = RLP.encodeValue(authTag)
+      val content = message.encode()
+      return Bytes.wrap(tag, rlpAuthTag, content)
+    }
+
+    val authHeader = handshakeParams?.let { authenticationProvider.authenticate(handshakeParams) }
+
+    val initiatorKey = authenticationProvider.findSessionKey(destNodeId.toHexString())?.initiatorKey
+      ?: throw IllegalArgumentException() // TODO handle
+    val messagePlain = Bytes.wrap(message.getMessageType(), message.encode())
+    return if (null != authHeader) {
+      val encodedHeader = authHeader.asRlp()
+      val authTag = authHeader.authTag
+      val encryptionMeta = Bytes.wrap(tag, encodedHeader)
+      val encryptionResult = AES128GCM.encrypt(initiatorKey, authTag, messagePlain, encryptionMeta)
+      Bytes.wrap(tag, encodedHeader, encryptionResult)
+    } else {
+      val authTag = UdpMessage.authTag()
+      val authTagHeader = RLP.encodeValue(authTag)
+      val encryptionResult = AES128GCM.encrypt(initiatorKey, authTag, messagePlain, tag)
+      Bytes.wrap(tag, authTagHeader, encryptionResult)
+    }
+  }
+
+  override fun decode(message: Bytes): DecodeResult {
+    val tag = message.slice(0, UdpMessage.TAG_LENGTH)
+    val senderNodeId = UdpMessage.getSourceFromTag(tag, nodeId)
+    val contentWithHeader = message.slice(UdpMessage.TAG_LENGTH)
+    val decodedMessage = RLP.decode(contentWithHeader) { reader -> read(tag, senderNodeId, contentWithHeader, reader) }
+    return DecodeResult(senderNodeId, decodedMessage)
+  }
+
+  private fun read(tag: Bytes, senderNodeId: Bytes, contentWithHeader: Bytes, reader: RLPReader): UdpMessage {
+    // Distinguish auth header or auth tag
+    var authHeader: AuthHeader? = null
+    if (reader.nextIsList()) {
+      if (WHO_ARE_YOU_MESSAGE_LENGTH == contentWithHeader.size()) {
+        return WhoAreYouMessage.create(contentWithHeader)
+      }
+      authHeader = reader.readList { listReader ->
+        val authenticationTag = listReader.readValue()
+        val idNonce = listReader.readValue()
+        val authScheme = listReader.readString()
+        val ephemeralPublicKey = listReader.readValue()
+        val authResponse = listReader.readValue()
+        return@readList AuthHeader(authenticationTag, idNonce, ephemeralPublicKey, authResponse, authScheme)
+      }
+      authenticationProvider.finalizeHandshake(senderNodeId, authHeader)
+    } else {
+      reader.readValue()
+    }
+
+    val encryptedContent = contentWithHeader.slice(reader.position())
+
+    // Decrypt
+    val decryptionKey = authenticationProvider.findSessionKey(senderNodeId.toHexString())?.initiatorKey
+      ?: return RandomMessage(encryptedContent)
+    val decryptMetadata = authHeader?.let { Bytes.wrap(tag, authHeader.asRlp()) } ?: tag
+    val decryptedContent = AES128GCM.decrypt(encryptedContent, decryptionKey, decryptMetadata)
+    val messageType = decryptedContent.slice(0, Byte.SIZE_BYTES)
+    val message = decryptedContent.slice(Byte.SIZE_BYTES)
+
+    // Retrieve result
+    return when (messageType.toInt()) {
+      3 -> FindNodeMessage.create(message)
+      else -> throw IllegalArgumentException("Unknown message retrieved")
+    }
+  }
+
+  companion object {
+    private const val WHO_ARE_YOU_MESSAGE_LENGTH = 48
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnector.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnector.kt
new file mode 100644
index 0000000..ebb4eb8
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnector.kt
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.v5.MessageHandler
+import org.apache.tuweni.devp2p.v5.PacketCodec
+import org.apache.tuweni.devp2p.v5.UdpConnector
+import org.apache.tuweni.devp2p.v5.internal.handler.RandomMessageHandler
+import org.apache.tuweni.devp2p.v5.internal.handler.WhoAreYouMessageHandler
+import org.apache.tuweni.devp2p.v5.packet.FindNodeMessage
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import org.apache.tuweni.net.coroutines.CoroutineDatagramChannel
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import java.util.logging.Logger
+import kotlin.coroutines.CoroutineContext
+
+class DefaultUdpConnector(
+  private val bindAddress: InetSocketAddress,
+  private val keyPair: SECP256K1.KeyPair,
+  private val selfEnr: Bytes,
+  private val nodeId: Bytes = Hash.sha2_256(selfEnr),
+  private val receiveChannel: CoroutineDatagramChannel = CoroutineDatagramChannel.open(),
+  private val sendChannel: CoroutineDatagramChannel = CoroutineDatagramChannel.open(),
+  private val packetCodec: PacketCodec = DefaultPacketCodec(keyPair, selfEnr),
+  override val coroutineContext: CoroutineContext = Dispatchers.IO
+) : UdpConnector, CoroutineScope {
+
+  private val log: Logger = Logger.getLogger(this.javaClass.simpleName)
+
+  private val randomMessageHandler: MessageHandler<RandomMessage> = RandomMessageHandler()
+  private val whoAreYouMessageHandler: MessageHandler<WhoAreYouMessage> = WhoAreYouMessageHandler(nodeId)
+
+  private val authenticatingPeers: MutableMap<InetSocketAddress, Bytes> = mutableMapOf()
+
+  private lateinit var receiveJob: Job
+
+  override fun start() {
+    receiveChannel.bind(bindAddress)
+
+    receiveJob = launch {
+      val datagram = ByteBuffer.allocate(UdpMessage.MAX_UDP_MESSAGE_SIZE)
+      while (receiveChannel.isOpen) {
+        datagram.clear()
+        val address = receiveChannel.receive(datagram) as InetSocketAddress
+        datagram.flip()
+        try {
+          processDatagram(datagram, address)
+        } catch (ex: Exception) {
+          log.warning(ex.message)
+        }
+      }
+    }
+  }
+
+  override fun send(
+    address: InetSocketAddress,
+    message: UdpMessage,
+    destNodeId: Bytes,
+    handshakeParams: HandshakeInitParameters?
+  ) {
+    launch {
+      val buffer = packetCodec.encode(message, destNodeId, handshakeParams)
+      sendChannel.send(ByteBuffer.wrap(buffer.toArray()), address)
+    }
+  }
+
+  override fun terminate() {
+    receiveJob.cancel()
+    receiveChannel.close()
+    sendChannel.close()
+  }
+
+  override fun available(): Boolean = receiveChannel.isOpen
+
+  override fun started(): Boolean = ::receiveJob.isInitialized && available()
+
+  override fun getEnr(): Bytes = selfEnr
+
+  override fun addPendingNodeId(address: InetSocketAddress, nodeId: Bytes) {
+    authenticatingPeers[address] = nodeId
+  }
+
+  override fun getNodeKeyPair(): SECP256K1.KeyPair = keyPair
+
+  override fun getPendingNodeIdByAddress(address: InetSocketAddress): Bytes {
+    val result = authenticatingPeers[address]
+      ?: throw IllegalArgumentException("Authenticated peer not found with address ${address.hostName}:${address.port}")
+    authenticatingPeers.remove(address)
+    return result
+  }
+
+  private fun processDatagram(datagram: ByteBuffer, address: InetSocketAddress) {
+    val messageBytes = Bytes.wrapByteBuffer(datagram)
+    val decodeResult = packetCodec.decode(messageBytes)
+    val message = decodeResult.message
+    when (message) {
+      is RandomMessage -> randomMessageHandler.handle(message, address, decodeResult.srcNodeId, this)
+      is WhoAreYouMessage -> whoAreYouMessageHandler.handle(message, address, decodeResult.srcNodeId, this)
+      is FindNodeMessage -> { } //TODO: response with NODES message
+      else -> throw IllegalArgumentException("Unexpected message has been received - ${message::class.java.simpleName}")
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/RandomMessageHandler.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/RandomMessageHandler.kt
new file mode 100644
index 0000000..477710a
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/RandomMessageHandler.kt
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal.handler
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.MessageHandler
+import org.apache.tuweni.devp2p.v5.UdpConnector
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import java.net.InetSocketAddress
+
+class RandomMessageHandler : MessageHandler<RandomMessage> {
+
+  override fun handle(message: RandomMessage, address: InetSocketAddress, srcNodeId: Bytes, connector: UdpConnector) {
+    connector.addPendingNodeId(address, srcNodeId)
+    val response = WhoAreYouMessage()
+    connector.send(address, response, srcNodeId)
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/WhoAreYouMessageHandler.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/WhoAreYouMessageHandler.kt
new file mode 100644
index 0000000..754ceae
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/internal/handler/WhoAreYouMessageHandler.kt
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal.handler
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.devp2p.v5.MessageHandler
+import org.apache.tuweni.devp2p.v5.UdpConnector
+import org.apache.tuweni.devp2p.v5.packet.FindNodeMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import java.net.InetSocketAddress
+
+class WhoAreYouMessageHandler(
+  private val nodeId: Bytes
+) : MessageHandler<WhoAreYouMessage> {
+
+  override fun handle(
+    message: WhoAreYouMessage,
+    address: InetSocketAddress,
+    srcNodeId: Bytes,
+    connector: UdpConnector
+  ) {
+    // Retrieve enr
+    val destRlp = connector.getPendingNodeIdByAddress(address)
+    val handshakeParams = HandshakeInitParameters(message.idNonce, message.authTag, destRlp)
+    val destNodeId = Hash.sha2_256(destRlp)
+
+    val findNodeMessage = FindNodeMessage()
+    connector.send(address, findNodeMessage, destNodeId, handshakeParams)
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/AuthHeader.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/AuthHeader.kt
new file mode 100644
index 0000000..0996d9f
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/AuthHeader.kt
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.misc
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class AuthHeader(
+  val authTag: Bytes,
+  val idNonce: Bytes,
+  val ephemeralPublicKey: Bytes,
+  val authResponse: Bytes,
+  val authScheme: String = AUTH_SCHEME
+) {
+
+  fun asRlp(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(authTag)
+      writer.writeValue(idNonce)
+      writer.writeValue(AUTH_SCHEME_BYTES)
+      writer.writeValue(ephemeralPublicKey)
+      writer.writeValue(authResponse)
+    }
+  }
+
+  companion object {
+    private const val AUTH_SCHEME: String = "gcm"
+
+    private val AUTH_SCHEME_BYTES: Bytes = Bytes.wrap(AUTH_SCHEME.toByteArray())
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/DecodeResult.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/DecodeResult.kt
new file mode 100644
index 0000000..8693e94
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/DecodeResult.kt
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.misc
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+
+class DecodeResult(
+  val srcNodeId: Bytes,
+  val message: UdpMessage
+)
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/HandshakeInitParameters.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/HandshakeInitParameters.kt
new file mode 100644
index 0000000..c9a6bc9
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/HandshakeInitParameters.kt
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.misc
+
+import org.apache.tuweni.bytes.Bytes
+
+class HandshakeInitParameters(
+  val idNonce: Bytes,
+  val authTag: Bytes,
+  val destEnr: Bytes
+)
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/SessionKey.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/SessionKey.kt
new file mode 100644
index 0000000..8d0f94a
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/misc/SessionKey.kt
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.misc
+
+import org.apache.tuweni.bytes.Bytes
+
+class SessionKey(
+  val initiatorKey: Bytes,
+  val recipientKey: Bytes,
+  val authRespKey: Bytes
+)
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessage.kt
new file mode 100644
index 0000000..b39ae46
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessage.kt
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class FindNodeMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val distance: Long = 0
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x03")
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeLong(distance)
+    }
+  }
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  companion object {
+    fun create(content: Bytes): FindNodeMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val distance = reader.readLong()
+        return@decodeList FindNodeMessage(requestId, distance)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessage.kt
new file mode 100644
index 0000000..5c213e5
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessage.kt
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class NodesMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val total: Int,
+  val nodeRecords: List<Bytes>
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x04")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeInt(total)
+      writer.writeList(nodeRecords) { listWriter, it ->
+        listWriter.writeValue(it)
+      }
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): NodesMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val total = reader.readInt()
+        val nodeRecords = reader.readListContents { listReader ->
+          listReader.readValue()
+        }
+        return@decodeList NodesMessage(requestId, total, nodeRecords)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessage.kt
new file mode 100644
index 0000000..c9cc06a
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessage.kt
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class PingMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val enrSeq: Long = 0
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x01")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { reader ->
+      reader.writeValue(requestId)
+      reader.writeLong(enrSeq)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): PingMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val enrSeq = reader.readLong()
+        return@decodeList PingMessage(requestId, enrSeq)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessage.kt
new file mode 100644
index 0000000..0323853
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessage.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+import java.net.InetAddress
+
+class PongMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val enrSeq: Long = 0,
+  val recipientIp: InetAddress,
+  val recipientPort: Int
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x02")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeLong(enrSeq)
+
+      val bytesIp = Bytes.wrap(recipientIp.address)
+      writer.writeValue(bytesIp)
+      writer.writeInt(recipientPort)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): PongMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val enrSeq = reader.readLong()
+        val address = InetAddress.getByAddress(reader.readValue().toArray())
+        val recipientPort = reader.readInt()
+        return@decodeList PongMessage(requestId, enrSeq, address, recipientPort)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessage.kt
new file mode 100644
index 0000000..e43ded7
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessage.kt
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+
+class RandomMessage(
+  val data: Bytes = randomData()
+) : UdpMessage() {
+
+  override fun encode(): Bytes {
+    return data
+  }
+
+  companion object {
+    fun create(content: Bytes): RandomMessage {
+      return RandomMessage(content)
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessage.kt
new file mode 100644
index 0000000..bd643dd
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessage.kt
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class RegConfirmationMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val registered: Boolean = true
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x08")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeByte(if (registered) 1 else 0)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): RegConfirmationMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val registered = (reader.readByte() == 1.toByte())
+        return@decodeList RegConfirmationMessage(requestId, registered)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessage.kt
new file mode 100644
index 0000000..afe2c23
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessage.kt
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class RegTopicMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val ticket: Bytes,
+  val nodeRecord: Bytes
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x07")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeValue(ticket)
+      writer.writeValue(nodeRecord)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): RegTopicMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val ticket = reader.readValue()
+        val nodeRecord = reader.readValue()
+        return@decodeList RegTopicMessage(requestId, ticket, nodeRecord)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessage.kt
new file mode 100644
index 0000000..0381a85
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessage.kt
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class ReqTicketMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val topic: Bytes
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x05")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeValue(topic)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): ReqTicketMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val topic = reader.readValue()
+        return@decodeList ReqTicketMessage(requestId, topic)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessage.kt
new file mode 100644
index 0000000..8d628f2
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessage.kt
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class TicketMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val ticket: Bytes,
+  val waitTime: Long
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x06")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeValue(ticket)
+      writer.writeLong(waitTime)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): TicketMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val ticket = reader.readValue()
+        val waitTime = reader.readLong()
+        return@decodeList TicketMessage(requestId, ticket, waitTime)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessage.kt
new file mode 100644
index 0000000..14736ff
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessage.kt
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class TopicQueryMessage(
+  val requestId: Bytes = UdpMessage.requestId(),
+  val topic: Bytes
+) : UdpMessage() {
+
+  private val encodedMessageType: Bytes = Bytes.fromHexString("0x09")
+
+  override fun getMessageType(): Bytes = encodedMessageType
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { writer ->
+      writer.writeValue(requestId)
+      writer.writeValue(topic)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): TopicQueryMessage {
+      return RLP.decodeList(content) { reader ->
+        val requestId = reader.readValue()
+        val topic = reader.readValue()
+        return@decodeList TopicQueryMessage(requestId, topic)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessage.kt
new file mode 100644
index 0000000..91ae779
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessage.kt
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+
+abstract class UdpMessage {
+
+  abstract fun encode(): Bytes
+
+  open fun getMessageType(): Bytes {
+    throw UnsupportedOperationException("Message don't identified with type")
+  }
+
+  companion object {
+
+    const val MAX_UDP_MESSAGE_SIZE = 1280
+    const val TAG_LENGTH: Int = 32
+    const val AUTH_TAG_LENGTH: Int = 12
+    const val RANDOM_DATA_LENGTH: Int = 44
+    const val ID_NONCE_LENGTH: Int = 32
+    const val REQUEST_ID_LENGTH: Int = 8
+
+    private val WHO_ARE_YOU: Bytes = Bytes.wrap("WHOAREYOU".toByteArray())
+
+    fun magic(dest: Bytes): Bytes {
+      val concatView = Bytes.wrap(dest, WHO_ARE_YOU)
+      return Hash.sha2_256(concatView)
+    }
+
+    fun tag(src: Bytes, dest: Bytes): Bytes {
+      val encodedDestKey = Hash.sha2_256(dest)
+      return Bytes.wrap(encodedDestKey).xor(src)
+    }
+
+    fun getSourceFromTag(tag: Bytes, dest: Bytes): Bytes {
+      val encodedDestKey = Hash.sha2_256(dest)
+      return Bytes.wrap(encodedDestKey).xor(tag)
+    }
+
+    fun requestId(): Bytes = Bytes.random(REQUEST_ID_LENGTH)
+
+    fun authTag(): Bytes = Bytes.random(AUTH_TAG_LENGTH)
+
+    fun randomData(): Bytes = Bytes.random(RANDOM_DATA_LENGTH)
+
+    fun idNonce(): Bytes = Bytes.random(ID_NONCE_LENGTH)
+  }
+}
diff --git a/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessage.kt b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessage.kt
new file mode 100644
index 0000000..8108294
--- /dev/null
+++ b/devp2p/src/main/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessage.kt
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.rlp.RLP
+
+class WhoAreYouMessage(
+  val authTag: Bytes = authTag(),
+  val idNonce: Bytes = idNonce(),
+  val enrSeq: Long = 0
+) : UdpMessage() {
+
+  override fun encode(): Bytes {
+    return RLP.encodeList { w ->
+      w.writeValue(authTag)
+      w.writeValue(idNonce)
+      w.writeLong(enrSeq)
+    }
+  }
+
+  companion object {
+    fun create(content: Bytes): WhoAreYouMessage {
+      return RLP.decodeList(content) { r ->
+        val authTag = r.readValue()
+        val idNonce = r.readValue()
+        val enrSeq = r.readLong()
+        return@decodeList WhoAreYouMessage(authTag, idNonce, enrSeq)
+      }
+    }
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/DefaultNodeDiscoveryServiceTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/DefaultNodeDiscoveryServiceTest.kt
new file mode 100644
index 0000000..a9f64e5
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/DefaultNodeDiscoveryServiceTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import kotlinx.coroutines.runBlocking
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.internal.DefaultUdpConnector
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.io.Base64URLSafe
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.apache.tuweni.net.coroutines.CoroutineDatagramChannel
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import java.time.Instant
+
+@ExtendWith(BouncyCastleExtension::class)
+class DefaultNodeDiscoveryServiceTest {
+
+  private val recipientKeyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val recipientEnr: Bytes =
+    EthereumNodeRecord.toRLP(recipientKeyPair, ip = InetAddress.getLocalHost(), udp = 9091)
+  private val encodedEnr: String = "enr:${Base64URLSafe.encode(recipientEnr)}"
+  private val keyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val localPort: Int = 9090
+  private val bindAddress: InetSocketAddress = InetSocketAddress(localPort)
+  private val bootstrapENRList: List<String> = listOf(encodedEnr)
+  private val enrSeq: Long = Instant.now().toEpochMilli()
+  private val selfENR: Bytes = EthereumNodeRecord.toRLP(
+    keyPair,
+    enrSeq,
+    emptyMap(),
+    bindAddress.address,
+    null,
+    bindAddress.port
+  )
+  private val connector: UdpConnector =
+    DefaultUdpConnector(bindAddress, keyPair, selfENR)
+
+  private val nodeDiscoveryService: NodeDiscoveryService =
+    DefaultNodeDiscoveryService(
+      keyPair,
+      localPort,
+      bindAddress,
+      bootstrapENRList,
+      enrSeq,
+      selfENR,
+      connector
+    )
+
+  @Test
+  fun startInitializesConnectorAndBootstraps() {
+    val recipientSocket = CoroutineDatagramChannel.open()
+    recipientSocket.bind(InetSocketAddress(9091))
+
+    nodeDiscoveryService.start()
+
+    runBlocking {
+      val buffer = ByteBuffer.allocate(UdpMessage.MAX_UDP_MESSAGE_SIZE)
+      recipientSocket.receive(buffer)
+      buffer.flip()
+      val receivedBytes = Bytes.wrapByteBuffer(buffer)
+      val content = receivedBytes.slice(45)
+
+      val message = RandomMessage.create(content)
+      assert(message.data.size() == UdpMessage.RANDOM_DATA_LENGTH)
+    }
+
+    assert(connector.started())
+
+    recipientSocket.close()
+    nodeDiscoveryService.terminate()
+  }
+
+  @Test
+  fun terminateShutsDownService() {
+    nodeDiscoveryService.start()
+
+    assert(connector.started())
+
+    nodeDiscoveryService.terminate(true)
+
+    assert(!connector.available())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/HandshakeIntegrationTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/HandshakeIntegrationTest.kt
new file mode 100644
index 0000000..46b0d47
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/HandshakeIntegrationTest.kt
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5
+
+import kotlinx.coroutines.runBlocking
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.internal.DefaultAuthenticationProvider
+import org.apache.tuweni.devp2p.v5.internal.DefaultPacketCodec
+import org.apache.tuweni.devp2p.v5.internal.DefaultUdpConnector
+import org.apache.tuweni.devp2p.v5.packet.FindNodeMessage
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import org.apache.tuweni.io.Base64URLSafe
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.apache.tuweni.net.coroutines.CoroutineDatagramChannel
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+@ExtendWith(BouncyCastleExtension::class)
+class HandshakeIntegrationTest {
+
+  private val keyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val enr: Bytes = EthereumNodeRecord.toRLP(keyPair, ip = InetAddress.getLocalHost(), udp = SERVICE_PORT)
+  private val address: InetSocketAddress = InetSocketAddress(InetAddress.getLocalHost(), SERVICE_PORT)
+  private val connector: UdpConnector = DefaultUdpConnector(address, keyPair, enr)
+
+  private val clientKeyPair = SECP256K1.KeyPair.random()
+  private val clientEnr = EthereumNodeRecord.toRLP(clientKeyPair, ip = InetAddress.getLocalHost(), udp = CLIENT_PORT)
+  private val authProvider = DefaultAuthenticationProvider(clientKeyPair, clientEnr)
+  private val clientCodec = DefaultPacketCodec(clientKeyPair, clientEnr, authenticationProvider = authProvider)
+  private val socket = CoroutineDatagramChannel.open()
+
+  private val clientNodeId: Bytes = Hash.sha2_256(clientEnr)
+
+  private val bootList = listOf("enr:${Base64URLSafe.encode(clientEnr)}")
+  private val service: NodeDiscoveryService =
+    DefaultNodeDiscoveryService(keyPair, SERVICE_PORT, bootstrapENRList = bootList, connector = connector)
+
+  @BeforeEach
+  fun init() {
+    socket.bind(InetSocketAddress(9091))
+    service.start()
+  }
+
+  @Test
+  fun discv5HandshakeTest() {
+    runBlocking {
+      val buffer = ByteBuffer.allocate(UdpMessage.MAX_UDP_MESSAGE_SIZE)
+      socket.receive(buffer)
+      buffer.flip()
+
+      var content = Bytes.wrapByteBuffer(buffer)
+      var decodingResult = clientCodec.decode(content)
+      assert(decodingResult.message is RandomMessage)
+      buffer.clear()
+
+      sendWhoAreYou()
+
+      socket.receive(buffer)
+      buffer.flip()
+      content = Bytes.wrapByteBuffer(buffer)
+      decodingResult = clientCodec.decode(content)
+      assert(decodingResult.message is FindNodeMessage)
+      assert(null != authProvider.findSessionKey(Hash.sha2_256(enr).toHexString()))
+
+      val message = decodingResult.message as FindNodeMessage
+
+      assert(message.distance == 0L)
+      assert(message.requestId.size() == UdpMessage.REQUEST_ID_LENGTH)
+    }
+  }
+
+  @AfterEach
+  fun tearDown() {
+    service.terminate(true)
+    socket.close()
+  }
+
+  private suspend fun sendWhoAreYou() {
+    val message = WhoAreYouMessage()
+    val bytes = clientCodec.encode(message, clientNodeId)
+    val buffer = ByteBuffer.wrap(bytes.toArray())
+    socket.send(buffer, address)
+  }
+
+  companion object {
+    private const val SERVICE_PORT: Int = 9090
+    private const val CLIENT_PORT: Int = 9091
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCMTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCMTest.kt
new file mode 100644
index 0000000..4146d05
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/AES128GCMTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.encrypt
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class AES128GCMTest {
+
+  @Test
+  fun encryptPerformsAES128GCMEncryption() {
+    val expectedResult = Bytes.fromHexString("0x000000207FC4FDB0E50ACBDA9CD993CFD3A3752104935B91F61B2AF2602C2" +
+      "DC4EFD97AFB943DAB6B1F5A0B13E83C41964F818AB8A51D6D30550BAE8B33A952AA1B6818AB88B66DBD60F5E016FA546808D983B70D")
+
+    val key = Bytes.fromHexString("0xA924872EAE2DA2C0057ED6DEBD8CAAB8")
+    val nonce = Bytes.fromHexString("0x7FC4FDB0E50ACBDA9CD993CFD3A3752104935B91F61B2AF2602C2DC4EFD97AFB")
+    val data = Bytes.fromHexString("0x19F23925525AF4C2697C1BED166EEB37B5381C10E508A27BCAA02CE661E62A2B")
+
+    val result = AES128GCM.encrypt(key, nonce, data, Bytes.EMPTY)
+
+    assert(result == expectedResult)
+  }
+
+  @Test
+  fun decryptPerformsAES128GCMDecryption() {
+    val expectedResult = Bytes.fromHexString("0x19F23925525AF4C2697C1BED166EEB37B5381C10E508A27BCAA02CE661E62A2B")
+
+    val encryptedData = Bytes.fromHexString("0x000000207FC4FDB0E50ACBDA9CD993CFD3A3752104935B91F61B2AF2602C2" +
+      "DC4EFD97AFB943DAB6B1F5A0B13E83C41964F818AB8A51D6D30550BAE8B33A952AA1B6818AB88B66DBD60F5E016FA546808D983B70D")
+    val key = Bytes.fromHexString("0xA924872EAE2DA2C0057ED6DEBD8CAAB8")
+
+    val result = AES128GCM.decrypt(encryptedData, key, Bytes.EMPTY)
+
+    assert(result == expectedResult)
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGeneratorTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGeneratorTest.kt
new file mode 100644
index 0000000..f517ed1
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/encrypt/SessionKeyGeneratorTest.kt
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.encrypt
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class SessionKeyGeneratorTest {
+
+  @Test
+  fun generateCreatesSessionKey() {
+    val expectedAuthRespKey = Bytes.fromHexString("0xDC999F3F7EF11907F6762497476117C9")
+    val expectedInitiatorKey = Bytes.fromHexString("0xBBBE757DCE9687BBE5E90CBF9C776163")
+    val expectedRecipientKey = Bytes.fromHexString("0xE83FC3ED3B32DEE7D81D706FECA6174F")
+
+    val srcNodeId = Bytes.fromHexString("0x9CE70B8F317791EB4E775FF9314B9B7B2CD01D90FF5D0E1979B2EBEB92DCB48D")
+    val destNodeId = Bytes.fromHexString("0x0B1E82724DB4D17089EF64A441A2C367683EAC448E6AB7F6F8B3094D2B1B2229")
+    val secret = Bytes.fromHexString("0xAB285AD41C712A917DAC83DE8AAD963285067ED84BAC37052A32BB74DCC75AA5")
+    val idNonce = Bytes.fromHexString("0x630222D6CD1253BF40CB800F230759F117EC1890CD76792135BBC4D7AAD0B4C1")
+
+    val result = SessionKeyGenerator.generate(srcNodeId, destNodeId, secret, idNonce)
+
+    assert(result.authRespKey == expectedAuthRespKey)
+    assert(result.initiatorKey == expectedInitiatorKey)
+    assert(result.recipientKey == expectedRecipientKey)
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProviderTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProviderTest.kt
new file mode 100644
index 0000000..cebe167
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultAuthenticationProviderTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.misc.HandshakeInitParameters
+import org.apache.tuweni.devp2p.v5.misc.SessionKey
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+
+@ExtendWith(BouncyCastleExtension::class)
+class DefaultAuthenticationProviderTest {
+
+  private val providerKeyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val providerEnr: Bytes = EthereumNodeRecord.toRLP(providerKeyPair, ip = InetAddress.getLocalHost())
+  private val authenticationProvider = DefaultAuthenticationProvider(providerKeyPair, providerEnr)
+
+  @Test
+  fun authenticateReturnsValidAuthHeader() {
+    val keyPair = SECP256K1.KeyPair.random()
+    val nonce = Bytes.fromHexString("0x012715E4EFA2464F51BE49BBC40836E5816B3552249F8AC00AD1BBDB559E44E9")
+    val authTag = Bytes.fromHexString("0x39BBC27C8CFA3735DF436AC6")
+    val destEnr = EthereumNodeRecord.toRLP(keyPair, ip = InetAddress.getLocalHost())
+    val params = HandshakeInitParameters(nonce, authTag, destEnr)
+
+    val result = authenticationProvider.authenticate(params)
+
+    assert(result.idNonce == nonce)
+    assert(result.authTag == authTag)
+    assert(result.authScheme == "gcm")
+    assert(result.ephemeralPublicKey != providerKeyPair.publicKey().bytes())
+
+    val destNodeId = Hash.sha2_256(destEnr).toHexString()
+
+    assert(authenticationProvider.findSessionKey(destNodeId) != null)
+  }
+
+  @Test
+  fun finalizeHandshakePersistsCreatedSessionKeys() {
+    val keyPair = SECP256K1.KeyPair.random()
+    val nonce = Bytes.fromHexString("0x012715E4EFA2464F51BE49BBC40836E5816B3552249F8AC00AD1BBDB559E44E9")
+    val authTag = Bytes.fromHexString("0x39BBC27C8CFA3735DF436AC6")
+    val destEnr = EthereumNodeRecord.toRLP(keyPair, ip = InetAddress.getLocalHost())
+    val params = HandshakeInitParameters(nonce, authTag, providerEnr)
+    val destNodeId = Hash.sha2_256(destEnr)
+
+    val clientAuthProvider = DefaultAuthenticationProvider(keyPair, destEnr)
+
+    val authHeader = clientAuthProvider.authenticate(params)
+
+    authenticationProvider.finalizeHandshake(destNodeId, authHeader)
+
+    assert(authenticationProvider.findSessionKey(destNodeId.toHexString()) != null)
+  }
+
+  @Test
+  fun findSessionKeyRetrievesSessionKeyIfExists() {
+    val result = authenticationProvider.findSessionKey(Bytes.random(32).toHexString())
+
+    assert(result == null)
+  }
+
+  @Test
+  fun setSessionKeyPersistsSessionKeyIfExists() {
+    val nodeId = Bytes.random(32).toHexString()
+    val bytes = Bytes.random(32)
+    val sessionKey = SessionKey(bytes, bytes, bytes)
+
+    authenticationProvider.setSessionKey(nodeId, sessionKey)
+
+    val result = authenticationProvider.findSessionKey(nodeId)
+
+    assert(result != null)
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodecTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodecTest.kt
new file mode 100644
index 0000000..85a08dd
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultPacketCodecTest.kt
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.Hash
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.AuthenticationProvider
+import org.apache.tuweni.devp2p.v5.PacketCodec
+import org.apache.tuweni.devp2p.v5.encrypt.AES128GCM
+import org.apache.tuweni.devp2p.v5.misc.SessionKey
+import org.apache.tuweni.devp2p.v5.packet.FindNodeMessage
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.devp2p.v5.packet.WhoAreYouMessage
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+
+@ExtendWith(BouncyCastleExtension::class)
+class DefaultPacketCodecTest {
+
+  private val keyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val enr: Bytes = EthereumNodeRecord.toRLP(keyPair, ip = InetAddress.getLocalHost())
+  private val nodeId: Bytes = Hash.sha2_256(enr)
+  private val authenticationProvider: AuthenticationProvider = DefaultAuthenticationProvider(keyPair, enr)
+
+  private val codec: PacketCodec = DefaultPacketCodec(keyPair, enr, nodeId, authenticationProvider)
+
+  private val destNodeId: Bytes = Bytes.random(32)
+
+  @Test
+  fun encodePerformsValidEncodingOfRandomMessasge() {
+    val message = RandomMessage()
+
+    val encodedResult = codec.encode(message, destNodeId)
+
+    val encodedContent = encodedResult.slice(45)
+    val result = RandomMessage.create(encodedContent)
+
+    assert(result.data == message.data)
+  }
+
+  @Test
+  fun encodePerformsValidEncodingOfWhoAreYouMessage() {
+    val message = WhoAreYouMessage()
+
+    val encodedResult = codec.encode(message, destNodeId)
+
+    val encodedContent = encodedResult.slice(32)
+    val result = WhoAreYouMessage.create(encodedContent)
+
+    assert(result.idNonce == message.idNonce)
+    assert(result.enrSeq == message.enrSeq)
+    assert(result.authTag == message.authTag)
+  }
+
+  @Test
+  fun encodePerformsValidEncodingOfMessagesWithTypeIncluded() {
+    val message = FindNodeMessage()
+
+    val key = Bytes.random(16)
+    val sessionKey = SessionKey(key, key, key)
+
+    authenticationProvider.setSessionKey(destNodeId.toHexString(), sessionKey)
+
+    val encodedResult = codec.encode(message, destNodeId)
+
+    val tag = encodedResult.slice(0, UdpMessage.TAG_LENGTH)
+    val encryptedContent = encodedResult.slice(45)
+    val content = AES128GCM.decrypt(encryptedContent, sessionKey.initiatorKey, tag).slice(1)
+    val result = FindNodeMessage.create(content)
+
+    assert(result.requestId == message.requestId)
+    assert(result.distance == message.distance)
+  }
+
+  @Test
+  fun encodeFailsIfSessionKeyIsNotExists() {
+    val message = FindNodeMessage()
+
+    assertThrows<IllegalArgumentException> {
+      codec.encode(message, destNodeId)
+    }
+  }
+
+  @Test
+  fun decodePerformsValidDecodingOfRandomMessasge() {
+    val message = RandomMessage()
+
+    val encodedResult = codec.encode(message, destNodeId)
+
+    val result = codec.decode(encodedResult).message as? RandomMessage
+
+    assert(null != result)
+    assert(result!!.data == message.data)
+  }
+
+  @Test
+  fun decodePerformsValidDecodingOfWhoAreYouMessage() {
+    val message = WhoAreYouMessage()
+
+    val encodedResult = codec.encode(message, destNodeId)
+
+    val result = codec.decode(encodedResult).message as? WhoAreYouMessage
+
+    assert(null != result)
+    assert(result!!.idNonce == message.idNonce)
+    assert(result.enrSeq == message.enrSeq)
+    assert(result.authTag == message.authTag)
+  }
+
+  @Test
+  fun decodePerformsValidDecodingOfMessagesWithTypeIncluded() {
+    val message = FindNodeMessage()
+
+    val key = Bytes.random(16)
+    val sessionKey = SessionKey(key, key, key)
+
+    authenticationProvider.setSessionKey(destNodeId.toHexString(), sessionKey)
+    authenticationProvider.setSessionKey(nodeId.toHexString(), sessionKey)
+
+    val encodedResult = codec.encode(message, nodeId)
+
+    val result = codec.decode(encodedResult).message as? FindNodeMessage
+
+    assert(result!!.requestId == message.requestId)
+    assert(result.distance == message.distance)
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnectorTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnectorTest.kt
new file mode 100644
index 0000000..a345208
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/internal/DefaultUdpConnectorTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.internal
+
+import kotlinx.coroutines.runBlocking
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.devp2p.v5.UdpConnector
+import org.apache.tuweni.devp2p.v5.packet.RandomMessage
+import org.apache.tuweni.devp2p.v5.packet.UdpMessage
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.apache.tuweni.net.coroutines.CoroutineDatagramChannel
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+@ExtendWith(BouncyCastleExtension::class)
+class DefaultUdpConnectorTest {
+
+  private val keyPair: SECP256K1.KeyPair = SECP256K1.KeyPair.random()
+  private val nodeId: Bytes = Bytes.fromHexString("0x98EB6D611291FA21F6169BFF382B9369C33D997FE4DC93410987E27796360640")
+  private val address: InetSocketAddress = InetSocketAddress(9090)
+  private val selfEnr: Bytes = EthereumNodeRecord.toRLP(keyPair, ip = address.address)
+
+  private val data: Bytes = UdpMessage.randomData()
+  private val message: RandomMessage = RandomMessage(data)
+
+  private var connector: UdpConnector = DefaultUdpConnector(address, keyPair, selfEnr, nodeId)
+
+  @BeforeEach
+  fun setUp() {
+    connector = DefaultUdpConnector(address, keyPair, selfEnr, nodeId)
+  }
+
+  @AfterEach
+  fun tearDown() {
+    if (connector.started()) {
+      connector.terminate()
+    }
+  }
+
+  @Test
+  fun startOpensChannelForMessages() {
+    connector.start()
+
+    assert(connector.available())
+  }
+
+  @Test
+  fun terminateShutdownsConnector() {
+    connector.start()
+
+    assert(connector.available())
+
+    connector.terminate()
+
+    assert(!connector.available())
+  }
+
+  @Test
+  fun sendSendsValidDatagram() {
+    connector.start()
+
+    val destNodeId = Bytes.random(32)
+
+    val receiverAddress = InetSocketAddress(InetAddress.getLocalHost(), 9091)
+    val socketChannel = CoroutineDatagramChannel.open()
+    socketChannel.bind(receiverAddress)
+
+    runBlocking {
+      connector.send(receiverAddress, message, destNodeId)
+      val buffer = ByteBuffer.allocate(UdpMessage.MAX_UDP_MESSAGE_SIZE)
+      socketChannel.receive(buffer) as InetSocketAddress
+      buffer.flip()
+
+      val messageContent = Bytes.wrapByteBuffer(buffer).slice(45)
+      val message = RandomMessage.create(messageContent)
+
+      assert(message.data == data)
+    }
+    socketChannel.close()
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessageTest.kt
new file mode 100644
index 0000000..22b323c
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/FindNodeMessageTest.kt
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class FindNodeMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val expectedEncodingResult = "0xCA88C6E32C5E89CAA75480"
+
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = FindNodeMessage(requestId)
+
+    val encodingResult = message.encode()
+    assert(encodingResult.toHexString() == expectedEncodingResult)
+
+    val decodingResult = FindNodeMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.distance == 0L)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = FindNodeMessage()
+
+    assert(3 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessageTest.kt
new file mode 100644
index 0000000..d65696d
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/NodesMessageTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.apache.tuweni.crypto.SECP256K1
+import org.apache.tuweni.devp2p.EthereumNodeRecord
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import java.net.InetAddress
+
+@ExtendWith(BouncyCastleExtension::class)
+class NodesMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val total = 10
+    val nodeRecords = listOf(
+      EthereumNodeRecord.toRLP(SECP256K1.KeyPair.random(), ip = InetAddress.getLocalHost(), udp = 9090),
+      EthereumNodeRecord.toRLP(SECP256K1.KeyPair.random(), ip = InetAddress.getLocalHost(), udp = 9091),
+      EthereumNodeRecord.toRLP(SECP256K1.KeyPair.random(), ip = InetAddress.getLocalHost(), udp = 9092)
+    )
+    val message = NodesMessage(requestId, total, nodeRecords)
+
+    val encodingResult = message.encode()
+
+    val decodingResult = NodesMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.total == 10)
+    assert(EthereumNodeRecord.fromRLP(decodingResult.nodeRecords[0]).udp() == 9090)
+    assert(EthereumNodeRecord.fromRLP(decodingResult.nodeRecords[1]).udp() == 9091)
+    assert(EthereumNodeRecord.fromRLP(decodingResult.nodeRecords[2]).udp() == 9092)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = NodesMessage(UdpMessage.requestId(), 0, emptyList())
+
+    assert(4 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessageTest.kt
new file mode 100644
index 0000000..554437d
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PingMessageTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class PingMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = PingMessage(requestId)
+
+    val encodingResult = message.encode()
+
+    val decodingResult = PingMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.enrSeq == message.enrSeq)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = PingMessage()
+
+    assert(1 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessageTest.kt
new file mode 100644
index 0000000..e0847c4
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/PongMessageTest.kt
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+import java.net.InetAddress
+
+class PongMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = PongMessage(requestId, 0, InetAddress.getLocalHost(), 9090)
+
+    val encodingResult = message.encode()
+
+    val decodingResult = PongMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.enrSeq == message.enrSeq)
+    assert(decodingResult.recipientIp == message.recipientIp)
+    assert(decodingResult.recipientPort == message.recipientPort)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = PongMessage(recipientIp = InetAddress.getLocalHost(), recipientPort = 9090)
+
+    assert(2 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessageTest.kt
new file mode 100644
index 0000000..2060816
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RandomMessageTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class RandomMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val expectedEncodingResult =
+      "0xB53CCF732982B8E950836D1E02898C8B38CFDBFDF86BC65C8826506B454E14618EA73612A0F5582C130FF666"
+
+    val data = Bytes.fromHexString(expectedEncodingResult)
+    val message = RandomMessage(data)
+
+    val encodingResult = message.encode()
+    assert(encodingResult.toHexString() == expectedEncodingResult)
+
+    val decodingResult = RandomMessage.create(encodingResult)
+
+    assert(decodingResult.data == data)
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessageTest.kt
new file mode 100644
index 0000000..65f61a6
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegConfirmationMessageTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class RegConfirmationMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = RegConfirmationMessage(requestId, false)
+
+    val encodingResult = message.encode()
+
+    val decodingResult = RegConfirmationMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.registered == message.registered)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = RegConfirmationMessage()
+
+    assert(8 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessageTest.kt
new file mode 100644
index 0000000..c33b43b
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/RegTopicMessageTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class RegTopicMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = RegTopicMessage(requestId, Bytes.random(32), Bytes.random(32))
+
+    val encodingResult = message.encode()
+
+    val decodingResult = RegTopicMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.ticket == message.ticket)
+    assert(decodingResult.nodeRecord == message.nodeRecord)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = RegTopicMessage(ticket = Bytes.random(32), nodeRecord = Bytes.random(32))
+
+    assert(7 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessageTest.kt
new file mode 100644
index 0000000..a43a328
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/ReqTicketMessageTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class ReqTicketMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = ReqTicketMessage(requestId, Bytes.random(32))
+
+    val encodingResult = message.encode()
+
+    val decodingResult = ReqTicketMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.topic == message.topic)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = ReqTicketMessage(topic = Bytes.random(32))
+
+    assert(5 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessageTest.kt
new file mode 100644
index 0000000..f754489
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TicketMessageTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class TicketMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = TicketMessage(requestId, Bytes.random(32), 1000)
+
+    val encodingResult = message.encode()
+
+    val decodingResult = TicketMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.ticket == message.ticket)
+    assert(decodingResult.waitTime == message.waitTime)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = TicketMessage(ticket = Bytes.random(32), waitTime = 1000)
+
+    assert(6 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessageTest.kt
new file mode 100644
index 0000000..36d66a8
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/TopicQueryMessageTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class TopicQueryMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val requestId = Bytes.fromHexString("0xC6E32C5E89CAA754")
+    val message = TopicQueryMessage(requestId, Bytes.random(32))
+
+    val encodingResult = message.encode()
+
+    val decodingResult = TopicQueryMessage.create(encodingResult)
+
+    assert(decodingResult.requestId == requestId)
+    assert(decodingResult.topic == message.topic)
+  }
+
+  @Test
+  fun getMessageTypeHasValidIndex() {
+    val message = TopicQueryMessage(topic = Bytes.random(32))
+
+    assert(9 == message.getMessageType().toInt())
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessageTest.kt
new file mode 100644
index 0000000..f6881cb
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/UdpMessageTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class UdpMessageTest {
+
+  @Test
+  fun magicCreatesSha256OfDestNodeIdAndConstantString() {
+    val destId = Bytes.fromHexString("0xA5CFE10E0EFC543CBE023560B2900E2243D798FAFD0EA46267DDD20D283CE13C")
+    val expected = Bytes.fromHexString("0x98EB6D611291FA21F6169BFF382B9369C33D997FE4DC93410987E27796360640")
+
+    val result = UdpMessage.magic(destId)
+
+    assert(expected == result)
+  }
+
+  @Test
+  fun tagHashesSourceAndDestNodeIdCorrectly() {
+    val srcId = Bytes.fromHexString("0x98EB6D611291FA21F6169BFF382B9369C33D997FE4DC93410987E27796360640")
+    val destId = Bytes.fromHexString("0xA5CFE10E0EFC543CBE023560B2900E2243D798FAFD0EA46267DDD20D283CE13C")
+    val expected = Bytes.fromHexString("0xB7A0D7CA8BD37611315DA0882FF479DE14B442FD30AE0EFBE6FC6344D55DC632")
+
+    val result = UdpMessage.tag(srcId, destId)
+
+    assert(expected == result)
+  }
+
+  @Test
+  fun getSourceFromTagFetchesSrcNodeId() {
+    val srcId = Bytes.fromHexString("0x98EB6D611291FA21F6169BFF382B9369C33D997FE4DC93410987E27796360640")
+    val destId = Bytes.fromHexString("0xA5CFE10E0EFC543CBE023560B2900E2243D798FAFD0EA46267DDD20D283CE13C")
+    val tag = UdpMessage.tag(srcId, destId)
+
+    val result = UdpMessage.getSourceFromTag(tag, destId)
+
+    assert(srcId == result)
+  }
+
+  @Test
+  fun authTagGivesRandom12Bytes() {
+    val firstResult = UdpMessage.authTag()
+
+    assert(UdpMessage.AUTH_TAG_LENGTH == firstResult.size())
+
+    val secondResult = UdpMessage.authTag()
+
+    assert(secondResult != firstResult)
+  }
+
+  @Test
+  fun randomDataGivesRandom44Bytes() {
+    val firstResult = UdpMessage.randomData()
+
+    assert(UdpMessage.RANDOM_DATA_LENGTH == firstResult.size())
+
+    val secondResult = UdpMessage.randomData()
+
+    assert(secondResult != firstResult)
+  }
+
+  @Test
+  fun idNonceGivesRandom32Bytes() {
+    val firstResult = UdpMessage.idNonce()
+
+    assert(UdpMessage.ID_NONCE_LENGTH == firstResult.size())
+
+    val secondResult = UdpMessage.idNonce()
+
+    assert(secondResult != firstResult)
+  }
+
+  @Test
+  fun getMessageTypeThrowsExceptionByDefault() {
+    assertThrows<UnsupportedOperationException> {
+      RandomMessage().getMessageType()
+    }
+  }
+}
diff --git a/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessageTest.kt b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessageTest.kt
new file mode 100644
index 0000000..12ad2d2
--- /dev/null
+++ b/devp2p/src/test/kotlin/org/apache/tuweni/devp2p/v5/packet/WhoAreYouMessageTest.kt
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tuweni.devp2p.v5.packet
+
+import org.apache.tuweni.bytes.Bytes
+import org.junit.jupiter.api.Test
+
+class WhoAreYouMessageTest {
+
+  @Test
+  fun encodeCreatesValidBytesSequence() {
+    val expectedEncodingResult =
+      "0xEF8C05D038D54B1ACB9A2A83C480A0C3B548CA063DA57BC9DE93340360AF32815FC8D0B2F053B3CB7918ABBB291A5180"
+
+    val authTag = Bytes.fromHexString("0x05D038D54B1ACB9A2A83C480")
+    val nonce = Bytes.fromHexString("0xC3B548CA063DA57BC9DE93340360AF32815FC8D0B2F053B3CB7918ABBB291A51")
+    val message = WhoAreYouMessage(authTag, nonce)
+
+    val encodingResult = message.encode()
+    assert(encodingResult.toHexString() == expectedEncodingResult)
+
+    val decodingResult = WhoAreYouMessage.create(encodingResult)
+
+    assert(decodingResult.authTag == authTag)
+    assert(decodingResult.idNonce == nonce)
+    assert(decodingResult.enrSeq == 0L)
+  }
+}
diff --git a/rlp/src/main/java/org/apache/tuweni/rlp/BytesRLPReader.java b/rlp/src/main/java/org/apache/tuweni/rlp/BytesRLPReader.java
index d96b012..0d43c43 100644
--- a/rlp/src/main/java/org/apache/tuweni/rlp/BytesRLPReader.java
+++ b/rlp/src/main/java/org/apache/tuweni/rlp/BytesRLPReader.java
@@ -196,6 +196,11 @@
     return (content.size() - index) == 0;
   }
 
+  @Override
+  public int position() {
+    return index;
+  }
+
   private Bytes readList(boolean lenient) {
     int remaining = content.size() - index;
     if (remaining == 0) {
diff --git a/rlp/src/main/java/org/apache/tuweni/rlp/RLPReader.java b/rlp/src/main/java/org/apache/tuweni/rlp/RLPReader.java
index f9cc643..05e279f 100644
--- a/rlp/src/main/java/org/apache/tuweni/rlp/RLPReader.java
+++ b/rlp/src/main/java/org/apache/tuweni/rlp/RLPReader.java
@@ -399,4 +399,11 @@
    * @return {@code true} if all values have been read.
    */
   boolean isComplete();
+
+  /**
+   * Returns reader's index
+   *
+   * @return current reader position
+   */
+  int position();
 }