Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/lean_spec/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ def resolve_bootnode(bootnode: str) -> str:

enr = ENR.from_string(bootnode)

# Verify structural validity (correct scheme, public key present).
if not enr.is_valid():
raise ValueError(f"ENR structurally invalid: {enr}")

# Cryptographically verify signature to ensure authenticity.
#
# This prevents attackers from forging ENRs to redirect connections.
if not enr.verify_signature():
raise ValueError(f"ENR signature verification failed: {enr}")

# ENR.multiaddr() returns None when the record lacks IP or TCP port.
#
# This happens with discovery-only ENRs that only contain UDP info.
Expand Down
211 changes: 174 additions & 37 deletions src/lean_spec/subspecs/networking/enr/enr.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,36 +57,26 @@
from typing_extensions import Self

from lean_spec.subspecs.networking.types import Multiaddr, NodeId, SeqNumber
from lean_spec.types import Bytes33, Bytes64, RLPDecodingError, StrictBaseModel, Uint64
from lean_spec.types.rlp import decode_list as rlp_decode_list
from lean_spec.types import (
Bytes32,
Bytes33,
Bytes64,
StrictBaseModel,
Uint64,
rlp,
)
from lean_spec.types.byte_arrays import Bytes4

from . import keys
from .eth2 import AttestationSubnets, Eth2Data
from .eth2 import AttestationSubnets, Eth2Data, SyncCommitteeSubnets
from .keys import EnrKey

ENR_PREFIX = "enr:"
"""Text prefix for ENR strings."""


class ENR(StrictBaseModel):
r"""
Ethereum Node Record (EIP-778).

Example from EIP-778 (IPv4 127.0.0.1, UDP 30303)::

enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04j...

Which decodes to RLP::

[
7098ad865b00a582..., # signature (64 bytes)
01, # seq = 1
"id", "v4",
"ip", 7f000001, # 127.0.0.1
"secp256k1", 03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138,
"udp", 765f, # 30303
]
"""
"""Ethereum Node Record (EIP-778)."""

MAX_SIZE: ClassVar[int] = 300
"""Maximum RLP-encoded size in bytes (EIP-778)."""
Expand Down Expand Up @@ -152,6 +142,18 @@ def udp_port(self) -> int | None:
port = self.get(keys.UDP)
return int.from_bytes(port, "big") if port else None

@property
def tcp6_port(self) -> int | None:
"""IPv6-specific TCP port. Falls back to tcp_port if not set."""
port = self.get(keys.TCP6)
return int.from_bytes(port, "big") if port else None

@property
def udp6_port(self) -> int | None:
"""IPv6-specific UDP port. Falls back to udp_port if not set."""
port = self.get(keys.UDP6)
return int.from_bytes(port, "big") if port else None

def multiaddr(self) -> Multiaddr | None:
"""Construct multiaddress from endpoint info."""
if self.ip4 and self.tcp_port:
Expand All @@ -160,18 +162,11 @@ def multiaddr(self) -> Multiaddr | None:
return f"/ip6/{self.ip6}/tcp/{self.tcp_port}"
return None

# =========================================================================
# Ethereum Consensus Extensions
# =========================================================================

@property
def eth2_data(self) -> Eth2Data | None:
"""Parse eth2 key: fork_digest(4) + next_fork_version(4) + next_fork_epoch(8)."""
eth2_bytes = self.get(keys.ETH2)
if eth2_bytes and len(eth2_bytes) >= 16:
from lean_spec.types import Uint64
from lean_spec.types.byte_arrays import Bytes4

return Eth2Data(
fork_digest=Bytes4(eth2_bytes[0:4]),
next_fork_version=Bytes4(eth2_bytes[4:8]),
Expand All @@ -185,9 +180,13 @@ def attestation_subnets(self) -> AttestationSubnets | None:
attnets = self.get(keys.ATTNETS)
return AttestationSubnets.decode_bytes(attnets) if attnets and len(attnets) == 8 else None

# =========================================================================
# Validation
# =========================================================================
@property
def sync_committee_subnets(self) -> SyncCommitteeSubnets | None:
"""Parse syncnets key (SSZ Bitvector[4])."""
syncnets = self.get(keys.SYNCNETS)
if syncnets and len(syncnets) == 1:
return SyncCommitteeSubnets.decode_bytes(syncnets)
return None

def is_valid(self) -> bool:
"""
Expand All @@ -207,9 +206,128 @@ def is_compatible_with(self, other: "ENR") -> bool:
return False
return self_eth2.fork_digest == other_eth2.fork_digest

# =========================================================================
# Display
# =========================================================================
def _build_content_items(self) -> list[bytes]:
"""
Build the list of content items for RLP encoding.

Returns [seq, k1, v1, k2, v2, ...] with keys sorted lexicographically.
"""
sorted_keys = sorted(self.pairs.keys())

# Sequence number: minimal big-endian, empty bytes for zero.
seq_bytes = self.seq.to_bytes(8, "big").lstrip(b"\x00") or b""
items: list[bytes] = [seq_bytes]

for key in sorted_keys:
items.append(key.encode("utf-8"))
items.append(self.pairs[key])

return items

def _content_rlp(self) -> bytes:
"""
Get RLP-encoded content for signing (excludes signature).

Returns the RLP encoding of [seq, k1, v1, k2, v2, ...].
"""
return rlp.encode_rlp(self._build_content_items())

def verify_signature(self) -> bool:
"""
Cryptographically verify the ENR signature.

Per EIP-778 "v4" identity scheme:

1. Compute keccak256 hash of content RLP (seq + sorted key/value pairs)
2. Verify the 64-byte secp256k1 signature against the public key

Returns True if signature is valid, False otherwise.
"""
from Crypto.Hash import keccak
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
Prehashed,
encode_dss_signature,
)

if self.public_key is None:
return False

try:
# Hash the content (excludes signature).
content = self._content_rlp()
k = keccak.new(digest_bits=256)
k.update(content)
digest = k.digest()

# Load the compressed public key.
public_key = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256K1(), bytes(self.public_key)
)

# Convert r||s (64 bytes) to DER-encoded signature.
r = int.from_bytes(self.signature[:32], "big")
s = int.from_bytes(self.signature[32:], "big")
der_signature = encode_dss_signature(r, s)

# Verify signature against pre-hashed digest.
# SHA256 is used as the algorithm marker since it has the same 32-byte digest size.
public_key.verify(der_signature, digest, ec.ECDSA(Prehashed(hashes.SHA256())))
return True
except Exception:
return False

def compute_node_id(self) -> NodeId | None:
"""
Compute the node ID from the public key.

Per EIP-778 "v4" identity scheme: keccak256(uncompressed_pubkey).
The hash is computed over the 64-byte x||y coordinates.
"""
from Crypto.Hash import keccak
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec

if self.public_key is None:
return None

try:
# Uncompress public key to 65 bytes (0x04 || x || y).
public_key = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256K1(), self.public_key
)
uncompressed = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)

# Hash the 64-byte x||y (excluding 0x04 prefix).
k = keccak.new(digest_bits=256)
k.update(uncompressed[1:])
return Bytes32(k.digest())
except Exception:
return None

def to_rlp(self) -> bytes:
"""
Serialize to RLP bytes.

Format: [signature, seq, k1, v1, k2, v2, ...]
Keys are sorted lexicographically per EIP-778.
"""
items = [bytes(self.signature)] + self._build_content_items()
return rlp.encode_rlp(items)

def to_string(self) -> str:
"""
Serialize to text representation.

Format: "enr:" + base64url(RLP) without padding.
"""
rlp_bytes = self.to_rlp()
b64_content = base64.urlsafe_b64encode(rlp_bytes).decode("utf-8").rstrip("=")
return ENR_PREFIX + b64_content

def __str__(self) -> str:
"""Human-readable summary."""
Expand Down Expand Up @@ -260,10 +378,14 @@ def from_string(cls, enr_text: str) -> Self:

# RLP decode: [signature, seq, k1, v1, k2, v2, ...]
try:
items = rlp_decode_list(rlp_data)
except RLPDecodingError as e:
items = rlp.decode_rlp_list(rlp_data)
except rlp.RLPDecodingError as e:
raise ValueError(f"Invalid RLP encoding: {e}") from e

# EIP-778 requires ENRs to be at most 300 bytes.
if len(rlp_data) > cls.MAX_SIZE:
raise ValueError(f"ENR exceeds max size: {len(rlp_data)} > {cls.MAX_SIZE}")

if len(items) < 2:
raise ValueError("ENR must have at least signature and seq")

Expand All @@ -281,14 +403,29 @@ def from_string(cls, enr_text: str) -> Self:
# Parse key/value pairs.
#
# Keys are strings, values are arbitrary bytes.
# EIP-778 requires keys to be lexicographically sorted.
pairs: dict[str, bytes] = {}
prev_key: str | None = None
for i in range(2, len(items), 2):
key = items[i].decode("utf-8")
if prev_key is not None and key <= prev_key:
raise ValueError(
f"ENR keys must be lexicographically sorted per EIP-778: "
f"'{key}' follows '{prev_key}'"
)
value = items[i + 1]
pairs[key] = value
prev_key = key

return cls(
enr = cls(
signature=signature,
seq=Uint64(seq),
pairs=pairs,
)

# Compute and store node_id for routing/identification.
node_id = enr.compute_node_id()
if node_id is not None:
return enr.model_copy(update={"node_id": node_id})

return enr
9 changes: 4 additions & 5 deletions src/lean_spec/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
SSZTypeError,
SSZValueError,
)
from .rlp import RLPDecodingError, RLPItem
from .rlp import decode as rlp_decode
from .rlp import encode as rlp_encode
from .rlp import RLPDecodingError, RLPItem, decode_rlp, decode_rlp_list, encode_rlp
from .ssz_base import SSZType
from .uint import Uint64

Expand All @@ -40,8 +38,9 @@
"Boolean",
"Container",
# RLP encoding/decoding
"rlp_encode",
"rlp_decode",
"encode_rlp",
"decode_rlp",
"decode_rlp_list",
"RLPItem",
"RLPDecodingError",
# Exceptions
Expand Down
10 changes: 5 additions & 5 deletions src/lean_spec/types/rlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"""Base for long list prefix. Final prefix = 0xf7 + length_of_length."""


def encode(item: RLPItem) -> bytes:
def encode_rlp(item: RLPItem) -> bytes:
"""
Encode an item using RLP.
Expand Down Expand Up @@ -124,7 +124,7 @@ def _encode_list(items: list[RLPItem]) -> bytes:
Long lists (>55 bytes payload) use prefix 0xf7 + length-of-length, then length.
"""
# Recursively encode all items.
payload = b"".join(encode(item) for item in items)
payload = b"".join(encode_rlp(item) for item in items)
length = len(payload)

# Short list: 0-55 bytes payload.
Expand Down Expand Up @@ -153,7 +153,7 @@ class RLPDecodingError(Exception):
"""Error during RLP decoding."""


def decode(data: bytes) -> RLPItem:
def decode_rlp(data: bytes) -> RLPItem:
"""
Decode RLP-encoded bytes.
Expand All @@ -177,7 +177,7 @@ def decode(data: bytes) -> RLPItem:
return item


def decode_list(data: bytes) -> list[bytes]:
def decode_rlp_list(data: bytes) -> list[bytes]:
"""
Decode RLP data as a flat list of byte items.
Expand All @@ -193,7 +193,7 @@ def decode_list(data: bytes) -> list[bytes]:
Raises:
RLPDecodingError: If data is not a list or contains nested lists.
"""
item = decode(data)
item = decode_rlp(data)

if not isinstance(item, list):
raise RLPDecodingError("Expected RLP list")
Expand Down
Loading
Loading