Skip to content

Commit

Permalink
Improvements to discv5 sessions (#773)
Browse files Browse the repository at this point in the history
- Adjust Sessions API to make use of results.Opt instead of
var params
- Add loadReadKey for when only the readKey is required
- Add 32-bit counter to a Session and add nextNonce call that
creates the nonce to use for next encryption (using the 32-bit
counter)
- Use nextNonce in discv5 encryption
- Tests for Sessions / nextNonce
  • Loading branch information
kdeme authored Feb 14, 2025
1 parent 51f56b0 commit e973d0b
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 49 deletions.
62 changes: 34 additions & 28 deletions eth/p2p/discoveryv5/encoding.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# nim-eth - Node Discovery Protocol v5
# Copyright (c) 2020-2024 Status Research & Development GmbH
# Copyright (c) 2020-2025 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
Expand Down Expand Up @@ -44,7 +44,6 @@ const
protocolId = toBytes(discv5_protocol_id)
idSignatureText = "discovery v5 identity proof"
keyAgreementPrefix = "discovery v5 key agreement"
gcmNonceSize* = 12
idNonceSize* = 16
gcmTagSize* = 16
ivSize* = 16
Expand Down Expand Up @@ -74,7 +73,6 @@ const
discv5TalkRespOverhead

type
AESGCMNonce* = array[gcmNonceSize, byte]
IdNonce* = array[idNonceSize, byte]

WhoareyouData* = object
Expand Down Expand Up @@ -221,8 +219,17 @@ proc encodeMessagePacket*(rng: var HmacDrbgContext, c: var Codec,
toId: NodeId, toAddr: Address, message: openArray[byte]):
(seq[byte], AESGCMNonce) =
let
nonce = rng.generate(AESGCMNonce) # Random AESGCM nonce
iv = rng.generate(array[ivSize, byte]) # Random IV
sessionOpt = c.sessions.load(toId, toAddr) # Load session if it exists
nonce =
# If there is an existing session, use the next nonce, else generate a
# fully random one.
if sessionOpt.isSome():
discovery_session_lru_cache_hits.inc()
sessionOpt.value.nextNonce(rng)
else:
discovery_session_lru_cache_misses.inc()
rng.generate(AESGCMNonce)

# static-header
let
Expand All @@ -235,23 +242,19 @@ proc encodeMessagePacket*(rng: var HmacDrbgContext, c: var Codec,
header.add(staticHeader)
header.add(authdata)

# message
var messageEncrypted: seq[byte]
var initiatorKey, recipientKey: AesKey
if c.sessions.load(toId, toAddr, recipientKey, initiatorKey):
messageEncrypted = encryptGCM(initiatorKey, nonce, message, @iv & header)
discovery_session_lru_cache_hits.inc()
else:
# We might not have the node's keys if the handshake hasn't been performed
# yet. That's fine, we send a random-packet and we will be responded with
# a WHOAREYOU packet.
# Select 20 bytes of random data, which is the smallest possible ping
# message. 16 bytes for the gcm tag and 4 bytes for ping with requestId of
# 1 byte (e.g "01c20101"). Could increase to 27 for 8 bytes requestId in
# case this must not look like a random packet.
let randomData = rng.generate(array[gcmTagSize + 4, byte])
messageEncrypted.add(randomData)
discovery_session_lru_cache_misses.inc()
# Encrypt protocol message
let messageEncrypted =
if sessionOpt.isSome():
encryptGCM(sessionOpt.value.writeKey, nonce, message, @iv & header)
else:
# We might not have the node's keys if the handshake hasn't been performed
# yet. That's fine, we send a random-packet and we will be responded with
# a WHOAREYOU packet.
# Select 20 bytes of random data, which is the smallest possible ping
# message. 16 bytes for the gcm tag and 4 bytes for ping with requestId of
# 1 byte (e.g "01c20101"). Could increase to 27 for 8 bytes requestId in
# case this must not look like a random packet.
@(rng.generate(array[gcmTagSize + 4, byte]))

let maskedHeader = encryptHeader(toId, iv, header)

Expand Down Expand Up @@ -306,9 +309,7 @@ proc encodeWhoareyouPacket*(rng: var HmacDrbgContext, c: var Codec,
proc encodeHandshakePacket*(rng: var HmacDrbgContext, c: var Codec,
toId: NodeId, toAddr: Address, message: openArray[byte],
whoareyouData: WhoareyouData, pubkey: PublicKey): seq[byte] =
let
nonce = rng.generate(AESGCMNonce)
iv = rng.generate(array[ivSize, byte]) # Random IV
let iv = rng.generate(array[ivSize, byte]) # Random IV

var authdata: seq[byte]
var authdataHead: seq[byte]
Expand All @@ -326,13 +327,19 @@ proc encodeHandshakePacket*(rng: var HmacDrbgContext, c: var Codec,
# compressed pub key format (33 bytes)
authdata.add(ephKeys.pubkey.toRawCompressed())

# Add ENR of sequence number is newer
# Add ENR if sequence number is newer
if whoareyouData.recordSeq < c.localNode.record.seqNum:
authdata.add(encode(c.localNode.record))

let secrets = deriveKeys(c.localNode.id, toId, ephKeys.seckey, pubkey,
whoareyouData.challengeData)

# Store session and get nonce
let session =
Session(readKey: secrets.recipientKey, writeKey: secrets.initiatorKey, counter: 0)
c.sessions.store(toId, toAddr, session)
let nonce = session.nextNonce(rng)

# Header
let staticHeader = encodeStaticHeader(Flag.HandshakeMessage, nonce,
authdata.len())
Expand All @@ -341,7 +348,7 @@ proc encodeHandshakePacket*(rng: var HmacDrbgContext, c: var Codec,
header.add(staticHeader)
header.add(authdata)

c.sessions.store(toId, toAddr, secrets.recipientKey, secrets.initiatorKey)
# Encrypt protocol message
let messageEncrypted = encryptGCM(secrets.initiatorKey, nonce, message,
@iv & header)

Expand Down Expand Up @@ -407,8 +414,7 @@ proc decodeMessagePacket(c: var Codec, fromAddr: Address, nonce: AESGCMNonce,
let srcId = NodeId.fromBytesBE(header.toOpenArray(staticHeaderSize,
header.high))

var initiatorKey, recipientKey: AesKey
if not c.sessions.load(srcId, fromAddr, recipientKey, initiatorKey):
let recipientKey = c.sessions.loadReadKey(srcId, fromAddr).valueOr:
# Don't consider this an error, simply haven't done a handshake yet or
# the session got removed.
trace "Decrypting failed (no keys)"
Expand Down
65 changes: 48 additions & 17 deletions eth/p2p/discoveryv5/sessions.nim
Original file line number Diff line number Diff line change
@@ -1,34 +1,65 @@
# nim-eth - Node Discovery Protocol v5
# Copyright (c) 2020-2024 Status Research & Development GmbH
# Copyright (c) 2020-2025 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.
#
## Session cache as mentioned at
## https://github.com/ethereum/devp2p/blob/master/discv5/discv5-theory.md#session-cache
## https://github.com/ethereum/devp2p/blob/5713591d0366da78a913a811c7502d9ca91d29a8/discv5/discv5-theory.md#session-cache
##

{.push raises: [].}

import
std/net,
bearssl/rand,
stint, stew/endians2,
./node, minilru

export minilru

const
aesKeySize* = 128 div 8
keySize = sizeof(NodeId) +
16 + # max size of ip address (ipv6)
2 # Sizeof port
gcmNonceSize* = 12
keySize =
sizeof(NodeId) +
16 + # max size of ip address (ipv6)
2 # size of port

type
AESGCMNonce* = array[gcmNonceSize, byte]
AesKey* = array[aesKeySize, byte]
SessionKey* = array[keySize, byte]
SessionValue* = array[sizeof(AesKey) + sizeof(AesKey), byte]
Sessions* = LruCache[SessionKey, SessionValue]
Session* = ref object
readKey*: AesKey
writeKey*: AesKey
counter*: uint32

Sessions* = LruCache[SessionKey, Session]

func nextNonce*(session: Session, rng: var HmacDrbgContext): AESGCMNonce =
# Generate nonce that is a concatenation of a 32-bit counter and a 64-bit random value.
# This is as is recommended in the discv5 spec:
# https://github.com/ethereum/devp2p/blob/5713591d0366da78a913a811c7502d9ca91d29a8/discv5/discv5-theory.md#session-cache
# The counter MUST be incremented after each use of the session writeKey.
# The recommendation when using 96-bit random nonce value is:
# "The total number of invocations of the authenticated encryption function shall not
# exceed 2^32, including all IV lengths and all instances of the authenticated
# encryption function with the given key."
# See NIST SP 800-38D, section 8:
# https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
# For the usage in discv5, this translates to 2^32 messages per session.
# A 32-bit counter + 64-bit random value nonce should increase that to 2^48 messages.
# The random component is added (opposed to a full 96-bit counter) to safeguard
# against nonce reuse in the case of counter cache/storage bugs.
var nonce: AESGCMNonce
nonce[0 .. 3] = session.counter.toBytesBE()
nonce[4 ..^ 1] = rng.generate(array[gcmNonceSize - 4, byte])

session.counter.inc()

nonce

func makeKey(id: NodeId, address: Address): SessionKey =
var pos = 0
Expand All @@ -42,21 +73,21 @@ func makeKey(id: NodeId, address: Address): SessionKey =
pos.inc(sizeof(address.ip.address_v6))
result[pos ..< pos+sizeof(address.port)] = toBytesBE(address.port.uint16)

func store*(s: var Sessions, id: NodeId, address: Address, session: Session) =
s.put(makeKey(id, address), session)

func store*(s: var Sessions, id: NodeId, address: Address, r, w: AesKey) =
var value: array[sizeof(r) + sizeof(w), byte]
value[0 .. 15] = r
value[16 .. ^1] = w
s.put(makeKey(id, address), value)
s.store(id, address, Session(readKey: r, writeKey: w, counter: 0))

func load*(s: var Sessions, id: NodeId, address: Address): Opt[Session] =
s.get(makeKey(id, address))

func load*(s: var Sessions, id: NodeId, address: Address, r, w: var AesKey): bool =
func loadReadKey*(s: var Sessions, id: NodeId, address: Address): Opt[AesKey] =
let res = s.get(makeKey(id, address))
if res.isSome():
let val = res.get()
copyMem(addr r[0], unsafeAddr val[0], sizeof(r))
copyMem(addr w[0], unsafeAddr val[sizeof(r)], sizeof(w))
return true
Opt.some(res.value().readKey)
else:
return false
Opt.none(AesKey)

func del*(s: var Sessions, id: NodeId, address: Address) =
s.del(makeKey(id, address))
1 change: 1 addition & 0 deletions tests/p2p/all_discv5_tests.nim
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import
./test_ip_vote,
./test_routing_table,
./test_discoveryv5_encoding,
./test_discoveryv5_sessions,
./test_discoveryv5
4 changes: 2 additions & 2 deletions tests/p2p/test_discoveryv5.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# nim-eth
# Copyright (c) 2020-2024 Status Research & Development GmbH
# Copyright (c) 2020-2025 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
Expand All @@ -18,7 +18,7 @@ import
../stubloglevel,
./discv5_test_helper

suite "Discovery v5 Tests":
suite "Discovery v5.1 Tests":
setup:
let rng {.used.} = newRng()

Expand Down
81 changes: 81 additions & 0 deletions tests/p2p/test_discoveryv5_sessions.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# nim-eth
# Copyright (c) 2025 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.

{.used.}

import
unittest2,
stew/endians2,
../../eth/common/keys,
../../eth/p2p/discoveryv5/[sessions, node],
./discv5_test_helper

suite "Discovery v5.1 Sessions":
setup:
let rng = newRng()

test "Sessions store/load":
var sessions = Sessions.init(256)

for i in 0..5:
let
key = PrivateKey.random(rng[])
nodeId = key.toPublicKey().toNodeId()
address = localAddress(9000+i)
readKey, writeKey = rng[].generate(array[16, byte])

sessions.store(nodeId, address, readKey, writeKey)

let sessionOpt = sessions.load(nodeId, address)
check:
sessionOpt.isSome()
sessionOpt.value().readKey == readKey
sessionOpt.value().writeKey == writeKey
sessionOpt.value().counter == 0

let readKeyOpt = sessions.loadReadKey(nodeId, address)
check:
readKeyOpt.isSome()
readKeyOpt.value() == readKey

test "Session counter":
let
readKey, writeKey = rng[].generate(array[16, byte])
session = Session(readKey: readKey, writeKey: writeKey, counter: 0)

let nonce0 = session.nextNonce(rng[])
check nonce0[0..3] == 0'u32.toBytesBE()

let nonce1 = session.nextNonce(rng[])
check nonce1[0..3] == 1'u32.toBytesBE()

test "Sessions store/load - session counter":
var sessions = Sessions.init(256)

let
key = PrivateKey.random(rng[])
nodeId = key.toPublicKey().toNodeId()
address = localAddress(9000)
readKey, writeKey = rng[].generate(array[16, byte])

sessions.store(nodeId, address, readKey, writeKey)

block:
let sessionOpt = sessions.load(nodeId, address)
check sessionOpt.isSome()
let session = sessionOpt.value()

let nonce = session.nextNonce(rng[])
check nonce[0..3] == 0'u32.toBytesBE()

block:
let sessionOpt = sessions.load(nodeId, address)
check sessionOpt.isSome()
let session = sessionOpt.value()

let nonce = session.nextNonce(rng[])
check nonce[0..3] == 1'u32.toBytesBE()
4 changes: 2 additions & 2 deletions tests/p2p/test_ip_vote.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# nim-eth
# Copyright (c) 2021-2024 Status Research & Development GmbH
# Copyright (c) 2021-2025 Status Research & Development GmbH
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
Expand All @@ -12,7 +12,7 @@ import
unittest2,
../../eth/common/keys, ../../eth/p2p/discoveryv5/[node, ip_vote]

suite "IP vote":
suite "Discovery v5.1 IP vote":
let rng = newRng()

test "Majority vote":
Expand Down

0 comments on commit e973d0b

Please sign in to comment.