From 20bb571690c276c666ffe49053a02f8675bee950 Mon Sep 17 00:00:00 2001 From: Sami Ahmed Date: Sun, 21 Jun 2026 16:48:58 -0700 Subject: [PATCH] tests: add v3 onion address validator + fix Phase 1.5 build break Adds src/test/onion_v3_tests.cpp with 8 Boost.Test cases that validate every hardcoded seed in src/onionseed.h against the v3 hidden service checksum algorithm (SHA3-256 of ".onion checksum" || pubkey || version). Test cases: * onion_v3_valid_known_seeds - all 7 hardcoded seeds must validate * onion_v3_detects_transposition - catches the btb6/gtb6 bug from 2026-06-21 * onion_v3_detects_wrong_length - too short, too long * onion_v3_detects_missing_suffix - .com instead of .onion * onion_v3_detects_invalid_base32 - chars 0,1,8,9 + uppercase rejected * onion_v3_detects_bad_version_byte - all-'a' body has invalid checksum * onion_v3_round_trip_encoding - base32 encode/decode is deterministic * onion_v3_audit_summary - overall summary check The C++ validator mirrors scripts/validate_onion_seeds.py exactly so the two implementations stay in sync. Catches corruption at CI/build time instead of daemon runtime. Also fixes an unrelated build break: GetPeerInflightCap() was called from syncmanager.cpp:533 but never declared in syncmanager.h. The function intent was 'windowSize / peerCount + 1' - inlined that here so the test build can succeed. --- src/syncmanager.cpp | 2 +- src/test/onion_v3_tests.cpp | 218 ++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/test/onion_v3_tests.cpp diff --git a/src/syncmanager.cpp b/src/syncmanager.cpp index 920535e..c12ea57 100644 --- a/src/syncmanager.cpp +++ b/src/syncmanager.cpp @@ -530,7 +530,7 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow) // Phase 1.5: per-peer inflight counting via HeaderNode.pnodeLastRequest. // Skip a peer if they're at their share of the global window. This caps the // damage a single .onion peer can do if they're feeding low-quality blocks. - const unsigned int nPerPeerCap = GetPeerInflightCap(vEligiblePeers.size()); + const unsigned int nPerPeerCap = HEADER_DOWNLOAD_WINDOW / std::max(1u, (unsigned int)vEligiblePeers.size()) + 1; std::map mapPeerInflight; { const int64_t nNowInflight = GetTime() * 1000000; diff --git a/src/test/onion_v3_tests.cpp b/src/test/onion_v3_tests.cpp new file mode 100644 index 0000000..aa0ff9e --- /dev/null +++ b/src/test/onion_v3_tests.cpp @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Cryptographic Triangles +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include "util.h" +#include "onionseed.h" + +#include +#include + +#include +#include + +// ═══════════════════════════════════════════════════════════════════════════════ +// v3 Onion Address Checksum Validation Tests +// ═══════════════════════════════════════════════════════════════════════════════ +// +// Background: 2026-06-21 from-zero sync test produced 4,842 Tor +// "No more HSDir available" errors and 181 "ed25519 validation failed" +// warnings. Root cause: a 1-character transposition (btb6 vs gtb6) in the +// test config's vmepp seed address. Tor correctly rejected the corrupted +// address but the error wasn't surfaced to a place where an operator would +// notice — the daemon just kept trying. +// +// These tests run the same v3 hidden service checksum validation that Tor +// runs internally, so we catch corruption at build/CI time instead of at +// daemon runtime. +// +// v3 onion = base32( PUBKEY(32) || CHECKSUM(2) || VERSION(1) ) +// PUBKEY = 32-byte ed25519 public key +// CHECKSUM = SHA3-256( ".onion checksum" || PUBKEY || VERSION )[:2] +// VERSION = 0x03 +// Total decoded = 35 bytes, base32-encoded to 56 chars + ".onion" suffix +// ═══════════════════════════════════════════════════════════════════════════════ + +namespace { + +static const std::string V3_CHECKSUM_INPUT = ".onion checksum"; +static const size_t V3_PUBKEY_LENGTH = 32; +static const size_t V3_DECODED_LENGTH = 35; + +/** Compute SHA3-256 of a byte vector. */ +std::vector sha3_256(const std::vector& data) { + std::vector out(EVP_MAX_MD_SIZE); + unsigned int out_len = 0; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + EVP_DigestInit_ex(ctx, EVP_sha3_256(), nullptr); + EVP_DigestUpdate(ctx, data.data(), data.size()); + EVP_DigestFinal_ex(ctx, out.data(), &out_len); + EVP_MD_CTX_free(ctx); + out.resize(out_len); + return out; +} + +/** + * Validate a v3 onion address against the hidden service checksum. + * + * @return true if address is a valid v3 onion, false otherwise + */ +bool IsValidV3Onion(const std::string& address) { + // Length check + if (address.size() != 62) return false; // 56 + ".onion" (6) + if (address.substr(56) != ".onion") return false; + + // Base32 alphabet check (lowercase a-z + 2-7) + for (size_t i = 0; i < 56; i++) { + char c = address[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7'))) return false; + } + + // Base32 decode + std::string padded = address.substr(0, 56); + while (padded.size() % 8 != 0) padded += '='; + bool invalid = false; + std::vector decoded = DecodeBase32(padded.c_str(), &invalid); + if (invalid) return false; + if (decoded.size() != V3_DECODED_LENGTH) return false; + + // v3 spec: PUBKEY(32) || CHECKSUM(2) || VERSION(1) + const unsigned char* pubkey = &decoded[0]; + const unsigned char* checksum = &decoded[32]; + unsigned char version = decoded[34]; + + if (version != 0x03) return false; + + // Compute expected checksum: SHA3-256( ".onion checksum" || pubkey || version )[:2] + std::vector input; + input.insert(input.end(), V3_CHECKSUM_INPUT.begin(), V3_CHECKSUM_INPUT.end()); + input.insert(input.end(), pubkey, pubkey + V3_PUBKEY_LENGTH); + input.push_back(version); + std::vector hash = sha3_256(input); + + return checksum[0] == hash[0] && checksum[1] == hash[1]; +} + +/** Helper to count the number of .onion entries in the hardcoded seed array. */ +size_t CountOnionSeeds() { + size_t count = 0; + for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) { + count++; + } + return count; +} + +} // anonymous namespace + +BOOST_AUTO_TEST_SUITE(onion_v3_tests) + +BOOST_AUTO_TEST_CASE(onion_v3_valid_known_seeds) +{ + // The 7 hardcoded seeds in src/onionseed.h MUST all be valid v3 onions. + // If any of these fail, Tor will reject them at runtime. + for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) { + std::string addr = strMainNetOnionSeed[i][0]; + std::string full = addr + ".onion"; + BOOST_CHECK_MESSAGE( + IsValidV3Onion(full), + "Hardcoded seed #" << i << " is not a valid v3 onion: " << full + ); + } +} + +BOOST_AUTO_TEST_CASE(onion_v3_detects_transposition) +{ + // The exact corruption found on 2026-06-21: + // Test config: vmepp7plxngv4qpyngbbtb6... (btb6, CORRUPT) + // Source/prod: vmepp7plxngv4qpyngbgtb6... (gtb6, valid) + const std::string VALID = "vmepp7plxngv4qpyngbgtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion"; + const std::string CORRUPT_BTB6 = "vmepp7plxngv4qpyngbbtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion"; + + BOOST_CHECK_MESSAGE(IsValidV3Onion(VALID), "gtb6 variant should be valid"); + BOOST_CHECK_MESSAGE(!IsValidV3Onion(CORRUPT_BTB6), + "btb6 variant should be REJECTED (this was the live bug)"); +} + +BOOST_AUTO_TEST_CASE(onion_v3_detects_wrong_length) +{ + // 55 chars (1 short) — should be rejected + BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.onio")); + // 57 chars (1 long) — should be rejected + BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3aaaa.onion")); + // Empty — should be rejected + BOOST_CHECK(!IsValidV3Onion("")); +} + +BOOST_AUTO_TEST_CASE(onion_v3_detects_missing_suffix) +{ + const std::string no_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a"; + BOOST_CHECK(!IsValidV3Onion(no_suffix)); + const std::string wrong_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.com"; + BOOST_CHECK(!IsValidV3Onion(wrong_suffix)); +} + +BOOST_AUTO_TEST_CASE(onion_v3_detects_invalid_base32) +{ + // '0' and '1' are not in the base32 alphabet + BOOST_CHECK(!IsValidV3Onion("0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijkl.onion")); + // '8' and '9' are not in the base32 alphabet + BOOST_CHECK(!IsValidV3Onion("89abcdefghijklmnopqrstuvwxyz23456789abcdefghijklmnopqrstuvwx.onion")); + // Uppercase should be rejected (we expect lowercase) + BOOST_CHECK(!IsValidV3Onion("A2Z4M7DQZMCSYJ4I6A5KPJ4TXR3C7YQT2QF3KZQGJ5UHWNAD6B3A.onion")); +} + +BOOST_AUTO_TEST_CASE(onion_v3_detects_bad_version_byte) +{ + // Construct an address with a non-v3 version byte by modifying the last + // char (which encodes the version byte's last 5 bits). For our purposes, + // we just need to verify that some random valid-looking string is rejected. + // The address below has the right length and valid base32, but its + // checksum bytes (derived from a fake pubkey) won't match the SHA3-256 + // of that fake pubkey. + const std::string fake = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"; + BOOST_CHECK_MESSAGE(!IsValidV3Onion(fake), + "Address with all-'a' body should have invalid checksum"); +} + +BOOST_AUTO_TEST_CASE(onion_v3_round_trip_encoding) +{ + // Encode and decode a known 35-byte input, verify the encoding is + // deterministic. This protects against accidental changes to the + // base32 implementation that could silently break v3 onion generation. + unsigned char input[35]; + for (int i = 0; i < 35; i++) input[i] = (unsigned char)(i * 7 + 13); + + std::string encoded = EncodeBase32(input, 35); + BOOST_CHECK_EQUAL(encoded.size(), 56u); + + bool invalid = false; + std::vector decoded = DecodeBase32(encoded.c_str(), &invalid); + BOOST_CHECK(!invalid); + BOOST_CHECK_EQUAL(decoded.size(), 35u); + for (int i = 0; i < 35; i++) { + BOOST_CHECK_EQUAL(decoded[i], input[i]); + } +} + +BOOST_AUTO_TEST_CASE(onion_v3_audit_summary) +{ + // Top-level summary: how many seeds are in onionseed.h + size_t n = CountOnionSeeds(); + BOOST_CHECK_MESSAGE(n >= 1, "Expected at least 1 hardcoded seed, found " << n); + + // All of them must validate + int nValid = 0, nInvalid = 0; + for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) { + if (IsValidV3Onion(std::string(strMainNetOnionSeed[i][0]) + ".onion")) { + nValid++; + } else { + nInvalid++; + } + } + BOOST_CHECK_EQUAL(nInvalid, 0); + BOOST_CHECK_EQUAL((size_t)nValid, n); +} + +BOOST_AUTO_TEST_SUITE_END()