From 85b53baeafb8f6b7d85d3aefc3ebaee831522b3d Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 7 Jun 2026 00:44:53 -0500 Subject: [PATCH] feat(mining): pin xmrig release-signing key + fix raw-signature parsing bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin the ed25519 public key in xmrig_updater.h, activating signature verification in soft mode (kXmrigRequireSignature=false): a release's ".sig" asset is verified when present, but an unsigned release still installs on TLS + SHA-256. Verified live against the current release (v6.25.2, which ships no .sig yet) — still installs. - gitignore *.ed25519.key / *.ed25519.pub.b64 so a signing secret key can never be committed. - Add a unit test that the pinned key decodes to a valid 32-byte ed25519 key (a malformed paste fails the build, not silently disabling verification). Bug fix (found via a flaky test): verifyXmrigSignature trimmed trailing whitespace BEFORE the raw-64-byte check, so a raw signature whose last byte equals '\n'/'\r'/space/tab (~1.6% of signatures) was corrupted and rejected. Now base64 is tried first (safe to trim) and the raw path uses the exact untrimmed bytes. Added a deterministic regression test that forces a whitespace-terminated raw signature. Suite is stable (0 failures in 10 runs; was ~3/8). Also de-brittled the live integration test: it no longer pins a release-specific binary hash (reaching Done already means the worker verified the binary against the release's own checksum). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +++ src/util/xmrig_updater.h | 5 ++-- src/util/xmrig_updater_core.cpp | 25 ++++++++++------- tests/test_phase4.cpp | 50 ++++++++++++++++++++++++++++----- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 04f6175..2d8bdb1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ asmap.dat .DS_Store # Local-only archive of superseded lite-wallet design/planning docs (untracked) docs/_archive/ + +# ed25519 release-signing keys — the secret key must NEVER be committed +*.ed25519.key +*.ed25519.pub.b64 diff --git a/src/util/xmrig_updater.h b/src/util/xmrig_updater.h index d1f3f67..05a8861 100644 --- a/src/util/xmrig_updater.h +++ b/src/util/xmrig_updater.h @@ -52,8 +52,9 @@ struct XmrigRelease { // raw 64-byte) ed25519 signature of the archive bytes (see scripts/sign-xmrig-release.sh). Once a // key is set, an install verifies the signature when the .sig asset is present; set // kXmrigRequireSignature=true to additionally refuse installs that publish no signature. -inline constexpr const char* kXmrigSignaturePublicKeyBase64 = ""; -inline constexpr bool kXmrigRequireSignature = false; +inline constexpr const char* kXmrigSignaturePublicKeyBase64 = + "j/9M+0E8NgcudP1q+23ar5uzwAFzQled8TtkFMaou6Q="; +inline constexpr bool kXmrigRequireSignature = false; // soft rollout: verify .sig if present // ── Pure helpers (no I/O; unit-tested) ─────────────────────────────────────── diff --git a/src/util/xmrig_updater_core.cpp b/src/util/xmrig_updater_core.cpp index cf9b0ba..c7aa480 100644 --- a/src/util/xmrig_updater_core.cpp +++ b/src/util/xmrig_updater_core.cpp @@ -178,18 +178,23 @@ bool verifyXmrigSignature(const std::string& data, unsigned char pubKey[crypto_sign_PUBLICKEYBYTES]; if (!base64ToFixed(pubKeyBase64, pubKey, sizeof(pubKey))) return false; - // Trim the signature content; accept either base64 or a raw 64-byte signature. - std::string sigText = signatureFileContent; - while (!sigText.empty() && - (sigText.back() == '\n' || sigText.back() == '\r' || - sigText.back() == ' ' || sigText.back() == '\t')) - sigText.pop_back(); + // Accept either base64 (what the .sig assets contain) or a raw 64-byte signature. Try base64 + // first on whitespace-trimmed text; only if that fails fall back to RAW bytes, using the EXACT + // untrimmed content — so a raw signature byte that happens to equal a whitespace value (~1.6% of + // random signatures end in one) is never stripped/corrupted. A raw 64-byte input can't be + // mistaken for base64: 64 base64 chars decode to 48 bytes, never 64, so base64ToFixed rejects it. + std::string trimmed = signatureFileContent; + while (!trimmed.empty() && + (trimmed.back() == '\n' || trimmed.back() == '\r' || + trimmed.back() == ' ' || trimmed.back() == '\t')) + trimmed.pop_back(); unsigned char sig[crypto_sign_BYTES]; - if (sigText.size() == crypto_sign_BYTES) { - std::memcpy(sig, sigText.data(), crypto_sign_BYTES); - } else if (!base64ToFixed(sigText, sig, sizeof(sig))) { - return false; + if (!base64ToFixed(trimmed, sig, sizeof(sig))) { + if (signatureFileContent.size() == crypto_sign_BYTES) + std::memcpy(sig, signatureFileContent.data(), crypto_sign_BYTES); + else + return false; } return crypto_sign_verify_detached( diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 62dfa76..9c13a98 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4054,6 +4054,22 @@ void testXmrigSignatureAssetSelection() EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1); } +void testXmrigPinnedKeyValidity() +{ + using namespace dragonx::util; + const std::string pin = kXmrigSignaturePublicKeyBase64; + if (pin.empty()) return; // signing disabled -> nothing to validate + // A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature + // verification would silently never succeed. Guards against a malformed/truncated paste. + EXPECT_TRUE(sodium_init() >= 0); + unsigned char pk[crypto_sign_PUBLICKEYBYTES]; + std::size_t n = 0; const char* end = nullptr; + const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n", + &n, &end, sodium_base64_VARIANT_ORIGINAL); + EXPECT_EQ(rc, 0); + EXPECT_EQ(n, static_cast(crypto_sign_PUBLICKEYBYTES)); +} + void testXmrigSignatureVerify() { using namespace dragonx::util; @@ -4079,6 +4095,27 @@ void testXmrigSignatureVerify() // Whitespace around the base64 signature is tolerated. EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64)); + // Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of + // signatures) must still verify — it must not be whitespace-trimmed. Force that case. + { + unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES], + rsig[crypto_sign_BYTES]; + bool found = false; + for (int i = 0; i < 20000 && !found; ++i) { + crypto_sign_keypair(rpk, rsk); + const std::string m = "raw-ws-regression-" + std::to_string(i); + crypto_sign_detached(rsig, nullptr, + reinterpret_cast(m.data()), m.size(), rsk); + const unsigned char last = rsig[crypto_sign_BYTES - 1]; + if (last == '\n' || last == '\r' || last == ' ' || last == '\t') { + found = true; + const std::string rawSig(reinterpret_cast(rsig), crypto_sign_BYTES); + EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk)))); + } + } + EXPECT_TRUE(found); + } + // Fails closed: tampered data, wrong key, malformed/empty inputs. EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64)); unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES]; @@ -4114,16 +4151,14 @@ void testXmrigLiveInstall() std::printf("[testXmrigLiveInstall] failed: %s\n", p.error.c_str()); return; } - // The miner binary was flattened out of the versioned subdir into the target dir, verified. + // The miner binary was flattened out of the versioned subdir into the target dir. We don't pin + // a release-specific hash here (releases change) — reaching State::Done already means the worker + // verified the binary against the release's own published SHA-256. Just assert a real binary landed. const std::string bin = dir + "/" + xmrigExtractBasenames(currentXmrigPlatformToken()).front(); EXPECT_TRUE(std::filesystem::exists(bin)); if (std::filesystem::exists(bin)) { - std::ifstream f(bin, std::ios::binary); - const std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - // On this (linux-x64) box the installed binary must match the published inner checksum. - if (currentXmrigPlatformToken() == "linux-x64") - EXPECT_EQ(sha256Hex(bytes.data(), bytes.size()), - std::string("37c178f743c269c1d9e18302cead0ed117ded2b5fe30910e836896b4abc20e57")); + std::error_code szEc; + EXPECT_TRUE(std::filesystem::file_size(bin, szEc) > 100000); // a real miner binary is MBs } std::filesystem::remove_all(dir, ec); } @@ -4186,6 +4221,7 @@ int main() testXmrigChecksumParsing(); testXmrigSha256AndBasenames(); testXmrigSignatureAssetSelection(); + testXmrigPinnedKeyValidity(); testXmrigSignatureVerify(); testXmrigLiveInstall(); testGeneratedResourceBehavior();