diff --git a/src/serde/CMakeLists.txt b/src/serde/CMakeLists.txt index 3294e41..8f54ae7 100644 --- a/src/serde/CMakeLists.txt +++ b/src/serde/CMakeLists.txt @@ -10,4 +10,5 @@ add_library(enr target_link_libraries(enr cppcodec p2p::libp2p + p2p::p2p_key_validator ) diff --git a/src/serde/enr.cpp b/src/serde/enr.cpp index ebafa0f..476ca6f 100644 --- a/src/serde/enr.cpp +++ b/src/serde/enr.cpp @@ -17,6 +17,27 @@ namespace lean::rlp { constexpr uint8_t kBytesPrefix1 = 0x80; constexpr uint8_t kListPrefix1 = 0xc0; + enum class Error { + INVALID_RLP, + INT_OVERFLOW, + INVALID_KEY_VALUE, + KEY_NOT_FOUND, + }; + Q_ENUM_ERROR_CODE(Error) { + using E = decltype(e); + switch (e) { + case E::INVALID_RLP: + return "Invalid rlp"; + case E::INT_OVERFLOW: + return "Int overflow"; + case E::INVALID_KEY_VALUE: + return "Invalid key-value"; + case E::KEY_NOT_FOUND: + return "Key not found"; + } + abort(); + } + struct Decoder { qtils::BytesIn input_; @@ -24,15 +45,20 @@ namespace lean::rlp { return input_.empty(); } - qtils::BytesIn _take(size_t n) { - assert(n <= input_.size()); + outcome::result take(size_t n) { + if (n > input_.size()) { + return Error::INVALID_RLP; + } auto r = input_.first(n); input_ = input_.subspan(n); return r; } template - static T _uint(qtils::BytesIn be) { + static outcome::result uint(qtils::BytesIn be) { + if (be.size() * 8 > std::numeric_limits::digits) { + return Error::INT_OVERFLOW; + } T v = 0; for (auto &x : be) { v = (v << 8) | x; @@ -41,75 +67,100 @@ namespace lean::rlp { } template - qtils::BytesIn _bytes() { + outcome::result bytesInternal() { constexpr auto base2 = base1 + kMaxPrefix1; - assert(base1 <= input_[0]); + if (base1 > input_[0]) { + return Error::INVALID_RLP; + } if (input_[0] <= base2) { auto n = input_[0] - base1; - _take(1); - return _take(n); + BOOST_OUTCOME_TRY(take(1)); + return take(n); } auto n1 = input_[0] - base2; - _take(1); - auto n2 = _uint(_take(n1)); - return _take(n2); + BOOST_OUTCOME_TRY(take(1)); + BOOST_OUTCOME_TRY(auto n2_raw, take(n1)); + BOOST_OUTCOME_TRY(auto n2, uint(n2_raw)); + return take(n2); } bool is_list() const { return not empty() and kListPrefix1 <= input_[0]; } - Decoder list() { - assert(not empty()); - return Decoder{_bytes()}; + outcome::result list() { + if (empty()) { + return Error::INVALID_RLP; + } + BOOST_OUTCOME_TRY(auto raw, bytesInternal()); + return Decoder{raw}; } - qtils::BytesIn bytes() { - assert(not empty()); - assert(input_[0] < kListPrefix1); + outcome::result bytes() { + if (empty()) { + return Error::INVALID_RLP; + } + if (input_[0] >= kListPrefix1) { + return Error::INVALID_RLP; + } if (input_[0] < kBytesPrefix1) { - return _take(1); + return take(1); } - return _bytes(); + return bytesInternal(); } template requires std::is_default_constructible_v and requires(T t) { qtils::BytesOut{t}; } - T bytes_n() { - auto raw = bytes(); + outcome::result bytes_n() { + BOOST_OUTCOME_TRY(auto raw, bytes()); T r; - assert(raw.size() == r.size()); + if (raw.size() != r.size()) { + return Error::INVALID_RLP; + } memcpy(r.data(), raw.data(), r.size()); return r; } template - qtils::ByteArr bytes_n() { + outcome::result> bytes_n() { return bytes_n>(); } - std::string_view str() { - return qtils::byte2str(bytes()); - } - - void str(std::string_view expected) { - auto actual = str(); - assert(actual == expected); + outcome::result str() { + BOOST_OUTCOME_TRY(auto raw, bytes()); + return qtils::byte2str(raw); } template - T uint() { - auto be = bytes(); - return _uint(be); + outcome::result uint() { + BOOST_OUTCOME_TRY(auto be, bytes()); + return uint(be); } - void skip() { + outcome::result skip() { if (is_list()) { - list(); + BOOST_OUTCOME_TRY(list()); } else { - bytes(); + BOOST_OUTCOME_TRY(bytes()); } + return outcome::success(); + } + + using KeyValue = std::unordered_map; + outcome::result keyValue() { + KeyValue kv; + while (not empty()) { + BOOST_OUTCOME_TRY(auto key, str()); + if (empty()) { + return Error::INVALID_KEY_VALUE; + } + auto value = input_; + BOOST_OUTCOME_TRY(skip()); + value = value.first(value.size() - input_.size()); + kv.emplace(key, Decoder{value}); + } + return kv; } }; @@ -124,7 +175,7 @@ namespace lean::rlp { return buffer_[0] < kBytesPrefix1; } template - void _uint(uint64_t v) { + void uint(uint64_t v) { auto n = sizeof(uint64_t) - std::countl_zero(v) / 8; size_ = 1 + n; buffer_[0] = base + n; @@ -133,12 +184,12 @@ namespace lean::rlp { } } template - void _bytes(size_t bytes) { + void bytesInternal(size_t bytes) { if (bytes <= kMaxPrefix1) { size_ = 1; buffer_[0] = base + bytes; } else { - _uint(bytes); + uint(bytes); } } }; @@ -149,7 +200,7 @@ namespace lean::rlp { size_ = 1; buffer_[0] = v; } else { - _uint(v); + uint(v); } } }; @@ -160,14 +211,14 @@ namespace lean::rlp { size_ = 1; buffer_[0] = bytes[0]; } else { - _bytes(bytes.size()); + bytesInternal(bytes.size()); } } }; struct EncodeList : EncodeBuffer { EncodeList(size_t bytes) { - _bytes(bytes); + bytesInternal(bytes); } }; @@ -200,6 +251,21 @@ namespace lean::rlp { } // namespace lean::rlp namespace lean::enr { + enum class Error { + INVALID_PREFIX, + INVALID_ID, + }; + Q_ENUM_ERROR_CODE(Error) { + using E = decltype(e); + switch (e) { + case E::INVALID_PREFIX: + return "Invalid ENR prefix"; + case E::INVALID_ID: + return "Invalid ENR id"; + } + abort(); + } + libp2p::PeerId Enr::peerId() const { return libp2p::peerIdFromSecp256k1(public_key); } @@ -227,36 +293,46 @@ namespace lean::enr { return {peerId(), {connectAddress()}}; } - Enr decode(std::string_view str) { + outcome::result decode(std::string_view str) { constexpr std::string_view s_enr{"enr:"}; - assert(str.starts_with(s_enr)); + if (not str.starts_with(s_enr)) { + return Error::INVALID_PREFIX; + } str.remove_prefix(s_enr.size()); auto rlp_bytes = cppcodec::base64_url_unpadded::decode(str); rlp::Decoder rlp{rlp_bytes}; - rlp = rlp.list(); + BOOST_OUTCOME_TRY(rlp, rlp.list()); Enr enr; - enr.signature = rlp.bytes_n(); - enr.sequence = rlp.uint(); - std::string_view key; - key = rlp.str(); - while (key != "id") { - rlp.skip(); - key = rlp.str(); - } - assert(key == "id"); - rlp.str("v4"); - key = rlp.str(); - if (key == "ip") { - enr.ip = rlp.bytes_n(); - key = rlp.str(); - } - assert(key == "secp256k1"); - enr.public_key = rlp.bytes_n(); - if (not rlp.empty()) { - rlp.str("udp"); - enr.port = rlp.uint(); - } - assert(rlp.empty()); + BOOST_OUTCOME_TRY(enr.signature, rlp.bytes_n()); + BOOST_OUTCOME_TRY(enr.sequence, rlp.uint()); + BOOST_OUTCOME_TRY(auto kv, rlp.keyValue()); + + auto kv_id = kv.find("id"); + if (kv_id == kv.end()) { + return rlp::Error::KEY_NOT_FOUND; + } + BOOST_OUTCOME_TRY(auto id, kv_id->second.str()); + if (id != "v4") { + return Error::INVALID_ID; + } + + auto kv_ip = kv.find("ip"); + if (kv_ip != kv.end()) { + BOOST_OUTCOME_TRY(enr.ip, kv_ip->second.bytes_n()); + } + + auto kv_secp256k1 = kv.find("secp256k1"); + if (kv_secp256k1 == kv.end()) { + return rlp::Error::KEY_NOT_FOUND; + } + BOOST_OUTCOME_TRY(enr.public_key, + kv_secp256k1->second.bytes_n()); + + auto kv_udp = kv.find("udp"); + if (kv_udp != kv.end()) { + BOOST_OUTCOME_TRY(enr.port, kv_udp->second.uint()); + } + return enr; } diff --git a/src/serde/enr.hpp b/src/serde/enr.hpp index ce98780..096a273 100644 --- a/src/serde/enr.hpp +++ b/src/serde/enr.hpp @@ -32,7 +32,7 @@ namespace lean::enr { libp2p::PeerInfo connectInfo() const; }; - Enr decode(std::string_view str); + outcome::result decode(std::string_view str); std::string encode(const Secp256k1PublicKey &public_key, Port port); } // namespace lean::enr diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index f239f55..5182bb1 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -7,3 +7,4 @@ add_subdirectory(app) add_subdirectory(blockchain) add_subdirectory(storage) +add_subdirectory(serde) diff --git a/tests/unit/serde/CMakeLists.txt b/tests/unit/serde/CMakeLists.txt new file mode 100644 index 0000000..ea7aa76 --- /dev/null +++ b/tests/unit/serde/CMakeLists.txt @@ -0,0 +1,14 @@ +# +# Copyright Quadrivium LLC +# All Rights Reserved +# SPDX-License-Identifier: Apache-2.0 +# + +addtest(enr_test + enr_test.cpp +) + +target_link_libraries(enr_test + enr +) + diff --git a/tests/unit/serde/enr_test.cpp b/tests/unit/serde/enr_test.cpp new file mode 100644 index 0000000..a241c0d --- /dev/null +++ b/tests/unit/serde/enr_test.cpp @@ -0,0 +1,125 @@ +// +// Copyright Quadrivium LLC +// All Rights Reserved +// SPDX-License-Identifier: Apache-2.0 +// + +#include "serde/enr.hpp" + +#include + +#include +#include + +#include + +using lean::enr::Enr; +using lean::enr::Ip; +using lean::enr::Port; +using lean::enr::Secp256k1PublicKey; +using lean::enr::Secp256k1Signature; + +namespace { + + // Known valid compressed secp256k1 public key: generator point G + static constexpr std::array kCompressedG{ + 0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, + 0x62, 0x95, 0xCE, 0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, + 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, 0x17, 0x98}; + + Secp256k1PublicKey makeKey() { + Secp256k1PublicKey k{}; + std::copy(kCompressedG.begin(), kCompressedG.end(), k.begin()); + return k; + } + +} // namespace + +TEST(EnrTest, EncodeDecodeRoundTrip) { + auto pub = makeKey(); + Port port = 9000; + + auto encoded = lean::enr::encode(pub, port); + // Encoded string must start with "enr:" + ASSERT_TRUE(encoded.rfind("enr:", 0) == 0) << encoded; + + ASSERT_OUTCOME_SUCCESS(enr, lean::enr::decode(encoded)); + + // Basic fields + EXPECT_EQ(enr.sequence, 1u); + EXPECT_EQ(enr.public_key, pub); + + // Signature is all-zero (not actually signed) + EXPECT_TRUE(std::all_of(enr.signature.begin(), + enr.signature.end(), + [](uint8_t b) { return b == 0; })); + + // IP and port present and correct + ASSERT_TRUE(enr.ip.has_value()); + // encode() uses Ip{1,0,0,127} + Ip expected_ip{1, 0, 0, 127}; + EXPECT_EQ(enr.ip.value(), expected_ip); + + ASSERT_TRUE(enr.port.has_value()); + EXPECT_EQ(enr.port.value(), port); + + // PeerId must be derivable and non-empty + auto pid = enr.peerId().toBase58(); + EXPECT_FALSE(pid.empty()); + + // Connect info wiring + auto info = enr.connectInfo(); + EXPECT_EQ(info.id, enr.peerId()); +} + +TEST(EnrTest, DeterministicEncoding) { + auto pub = makeKey(); + Port port = 12345; + auto e1 = lean::enr::encode(pub, port); + auto e2 = lean::enr::encode(pub, port); + EXPECT_EQ(e1, e2); + + // Changing port changes ENR + auto e3 = lean::enr::encode(pub, static_cast(port + 1)); + EXPECT_NE(e1, e3); + + // Changing key changes ENR + auto pub2 = pub; + pub2[32] ^= 0x01; // tweak + auto e4 = lean::enr::encode(pub2, port); + EXPECT_NE(e1, e4); +} + +TEST(EnrTest, DecodeGivenEnrAddress) { + // Provided ENR string from user request + // clang-format off + std::string_view addr = + "enr:-Ku4QHqVeJ8PPICcWk1vSn_XcSkjOkNiTg6Fmii5j6vUQgvzMc9L1goFnLKgXqBJspJjIsB91LTOleFmyWWrFVATGngBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhAMRHkWJc2VjcDI1NmsxoQKLVXFOhp2uX6jeT0DvvDpPcU8FWMjQdR4wMuORMhpX24N1ZHCCIyg"; + // clang-format on + + ASSERT_OUTCOME_SUCCESS(enr, lean::enr::decode(addr)); + + // Sequence should be positive + EXPECT_GT(enr.sequence, 0u); + + // IP should be present and equal to 3.17.30.69 + ASSERT_TRUE(enr.ip.has_value()); + Ip expected_ip{3, 17, 30, 69}; + EXPECT_EQ(enr.ip.value(), expected_ip); + + // UDP port should be present; expected commonly used 9000 + ASSERT_TRUE(enr.port.has_value()); + EXPECT_EQ(enr.port.value(), static_cast(9000)); + + // Public key must be compressed secp256k1 (33 bytes) with a valid prefix + EXPECT_EQ(enr.public_key.size(), 33u); + EXPECT_TRUE(enr.public_key[0] == 0x02 || enr.public_key[0] == 0x03); + + // PeerId derivation must succeed + auto pid = enr.peerId().toBase58(); + EXPECT_FALSE(pid.empty()); + + // Connect info wiring + auto info = enr.connectInfo(); + EXPECT_EQ(info.id, enr.peerId()); +} diff --git a/vcpkg-overlay/qtils/portfile.cmake b/vcpkg-overlay/qtils/portfile.cmake index 77ded8d..994cd5f 100644 --- a/vcpkg-overlay/qtils/portfile.cmake +++ b/vcpkg-overlay/qtils/portfile.cmake @@ -2,8 +2,8 @@ vcpkg_check_linkage(ONLY_STATIC_LIBRARY) vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO qdrvm/qtils - REF refs/tags/v0.1.4 - SHA512 124f3711eb64df3a2e207bff8bf953ccc2dfa838f21da72a1cc77c8aec95def350e70607adf9d8e7123e56d5bffcf830052f607dfa12badc7efe463bd0be747c + REF refs/tags/v0.1.5 + SHA512 12fe763fdfab70bb90fb8687efdde63bb0b04c0e3f50efea73115997ed278892e99e2b49fa13b9fa80d6e4740dc0c9942c16162c31ab3890e4eb09e1f7a81bc4 ) vcpkg_cmake_configure(SOURCE_PATH "${SOURCE_PATH}") vcpkg_cmake_install()