20bb571690
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.
219 lines
8.7 KiB
C++
219 lines
8.7 KiB
C++
// 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 <boost/test/unit_test.hpp>
|
|
|
|
#include "util.h"
|
|
#include "onionseed.h"
|
|
|
|
#include <openssl/evp.h>
|
|
#include <openssl/sha.h>
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 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<unsigned char> sha3_256(const std::vector<unsigned char>& data) {
|
|
std::vector<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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()
|