From 8765fdf36270280fae682a3fd36e0bfbdba01f2a Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 6 Jun 2026 20:29:49 -0500 Subject: [PATCH] feat(mining): opt-in ed25519 signature verification for the xmrig updater (#1) Closes the supply-chain gap the review flagged: today the archive and its SHA-256 share one trust root (the release body), so a compromised/edited release can ship an arbitrary binary that still "verifies". This adds authenticity via a detached ed25519 signature checked against a public key PINNED IN THE BINARY (not fetched), using libsodium's crypto_sign_verify_detached. Opt-in / soft rollout: - kXmrigSignaturePublicKeyBase64 in xmrig_updater.h is EMPTY by default -> signatures are not checked and behavior is unchanged (TLS + SHA-256 only). Paste the base64 public key to enable. - Once a key is pinned, an install verifies a ".sig" asset (base64/raw 64-byte ed25519 signature over the archive bytes) when present; kXmrigRequireSignature=true additionally refuses installs that publish no signature. - The check runs after the SHA-256 check, over the same already-read archive bytes; refuses on a missing key-but-required, unreachable .sig, or invalid signature. - verifyXmrigSignature + selectXmrigSignatureAsset are pure (libsodium only) and unit-tested: valid base64 + raw-64-byte signatures verify; tampered data, wrong key, and malformed/empty inputs all fail closed. Cross-tool interop verified (Python stdlib base64 == sodium base64). - scripts/sign-xmrig-release.sh: keygen / sign / pubkey helper (PyNaCl = same libsodium ed25519) to produce the .sig assets and the public key to pin. No behavior change until a key is pinned. Both variants build; suite passes; live worker re-verified (signatures off by default). Co-Authored-By: Claude Opus 4.8 --- scripts/sign-xmrig-release.sh | 72 +++++++++++++++++++++++++++++++++ src/util/xmrig_updater.cpp | 30 +++++++++++++- src/util/xmrig_updater.h | 23 +++++++++++ src/util/xmrig_updater_core.cpp | 52 ++++++++++++++++++++++++ tests/test_phase4.cpp | 49 ++++++++++++++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100755 scripts/sign-xmrig-release.sh diff --git a/scripts/sign-xmrig-release.sh b/scripts/sign-xmrig-release.sh new file mode 100755 index 0000000..ae5791d --- /dev/null +++ b/scripts/sign-xmrig-release.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Sign DRG-XMRig release archives for the wallet's in-app updater (opt-in ed25519 signatures). +# +# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public +# key pinned in src/util/xmrig_updater.h (kXmrigSignaturePublicKeyBase64). For each archive +# .zip this produces .zip.sig containing the base64 ed25519 signature — upload that +# .sig next to the .zip as a release asset. +# +# Keys are 32-byte ed25519: the secret key signs (keep OFFLINE), the public key goes in the wallet. +# +# Usage: +# scripts/sign-xmrig-release.sh keygen [out-prefix] # -> .ed25519.{key,pub.b64} +# scripts/sign-xmrig-release.sh sign ... # -> .sig per file +# scripts/sign-xmrig-release.sh pubkey # print the base64 public key to pin +# +# Requires python3 with PyNaCl (pip install pynacl). PyNaCl uses the same libsodium primitives the +# wallet verifies with, so signatures are guaranteed compatible. + +set -euo pipefail + +die() { echo "error: $*" >&2; exit 1; } +command -v python3 >/dev/null || die "python3 not found" +python3 -c 'import nacl.signing' 2>/dev/null || die "PyNaCl missing — run: pip install pynacl" + +cmd="${1:-}"; shift || true + +case "$cmd" in + keygen) + prefix="${1:-drg-xmrig}" + python3 - "$prefix" <<'PY' +import sys, base64, nacl.signing +prefix = sys.argv[1] +sk = nacl.signing.SigningKey.generate() +open(prefix + ".ed25519.key", "wb").write(bytes(sk)) +import os; os.chmod(prefix + ".ed25519.key", 0o600) +pub_b64 = base64.standard_b64encode(bytes(sk.verify_key)).decode() +open(prefix + ".ed25519.pub.b64", "w").write(pub_b64 + "\n") +print("secret key : %s.ed25519.key (KEEP OFFLINE, mode 600)" % prefix) +print("public key : %s.ed25519.pub.b64" % prefix) +print() +print("Pin this in src/util/xmrig_updater.h (kXmrigSignaturePublicKeyBase64):") +print(" %s" % pub_b64) +PY + ;; + sign) + [ $# -ge 2 ] || die "usage: sign ..." + keyfile="$1"; shift + for f in "$@"; do + [ -f "$f" ] || die "no such file: $f" + python3 - "$keyfile" "$f" <<'PY' +import sys, base64, nacl.signing +keyfile, f = sys.argv[1], sys.argv[2] +sk = nacl.signing.SigningKey(open(keyfile, "rb").read()) +sig = sk.sign(open(f, "rb").read()).signature # 64-byte detached ed25519 signature +open(f + ".sig", "w").write(base64.standard_b64encode(sig).decode() + "\n") +print("signed: %s -> %s.sig" % (f, f)) +PY + done + echo "Upload each .sig as a release asset next to its archive." + ;; + pubkey) + [ $# -ge 1 ] || die "usage: pubkey " + python3 - "$1" <<'PY' +import sys, base64, nacl.signing +sk = nacl.signing.SigningKey(open(sys.argv[1], "rb").read()) +print(base64.standard_b64encode(bytes(sk.verify_key)).decode()) +PY + ;; + *) + die "usage: $0 {keygen [prefix] | sign ... | pubkey }" + ;; +esac diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 1519866..9adc503 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -264,7 +264,10 @@ void XmrigUpdater::runInstall(std::string targetDir) } if (cancel_requested_) { fs::remove(zipPath, ec); setProgress(State::Failed, "Cancelled."); return; } - // 2. Verify the archive SHA-256 against the published checksum (refuse on missing/mismatch). + // 2. Verify the downloaded archive. Read it once, then (a) compare its SHA-256 to the published + // checksum and (b) — when a signing key is pinned — verify a detached ed25519 signature over + // the archive bytes against that key, so a checksum rewritten in a tampered release body is + // not sufficient to install. setProgress(State::Verifying, "Verifying download…"); { std::ifstream f(zipPath, std::ios::binary); @@ -279,6 +282,8 @@ void XmrigUpdater::runInstall(std::string targetDir) setProgress(State::Failed, "Could not read the downloaded archive."); return; } + + // 2a. SHA-256 (integrity / transit corruption). const std::string actual = sha256Hex(bytes.data(), bytes.size()); const auto it = checksums.find(asset.name); if (it == checksums.end()) { @@ -291,6 +296,29 @@ void XmrigUpdater::runInstall(std::string targetDir) setProgress(State::Failed, "Archive checksum mismatch — refusing to install (possible tampering)."); return; } + + // 2b. Detached signature (authenticity) — only when a public key is pinned (opt-in). + const std::string pubKey = kXmrigSignaturePublicKeyBase64; + if (!pubKey.empty()) { + const int sigIdx = selectXmrigSignatureAsset(rel, asset.name); + if (sigIdx < 0) { + if (kXmrigRequireSignature) { + fs::remove(zipPath, ec); + setProgress(State::Failed, "No signature published for this release — refusing to install."); + return; + } + DEBUG_LOGF("[xmrig-updater] no signature asset for %s; proceeding on checksum only\n", + asset.name.c_str()); + } else { + setProgress(State::Verifying, "Verifying signature…"); + const std::string sigContent = httpGet(rel.assets[sigIdx].downloadUrl); + if (sigContent.empty() || !verifyXmrigSignature(bytes, sigContent, pubKey)) { + fs::remove(zipPath, ec); + setProgress(State::Failed, "Signature verification failed — refusing to install."); + return; + } + } + } } // 3. Extract the wanted binaries (flatten the versioned subdir), verifying the miner binary's diff --git a/src/util/xmrig_updater.h b/src/util/xmrig_updater.h index aff8088..d1f3f67 100644 --- a/src/util/xmrig_updater.h +++ b/src/util/xmrig_updater.h @@ -43,6 +43,18 @@ struct XmrigRelease { std::string error; }; +// ── Release signature (opt-in supply-chain hardening) ──────────────────────── +// +// Pinned ed25519 public key (base64, 32 bytes) used to verify a detached signature over the +// downloaded archive. EMPTY by default = signature verification DISABLED (TLS + the release's +// published SHA-256 are the only gates, unchanged behavior). To enable: paste the base64 public +// key here and have the release publish a ".sig" asset containing the base64 (or +// 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; + // ── Pure helpers (no I/O; unit-tested) ─────────────────────────────────────── // Parse the Gitea GET /releases/latest JSON into an XmrigRelease (ok=false + error on failure). @@ -68,6 +80,17 @@ std::string sha256Hex(const void* data, std::size_t len); // {"xmrig.exe", "WinRing0x64.sys"} on Windows. First entry is always the miner binary. std::vector xmrigExtractBasenames(const std::string& platformToken); +// Index of the detached-signature asset for a given archive (name ".sig" or +// ".minisig"), or -1 if none is published. +int selectXmrigSignatureAsset(const XmrigRelease& release, const std::string& archiveName); + +// Verify a detached ed25519 signature over `data`. `signatureFileContent` is the .sig asset body +// (base64 of, or raw, a 64-byte ed25519 signature; whitespace tolerated). `pubKeyBase64` is the +// base64 32-byte public key. Returns true only on a cryptographically valid signature. +bool verifyXmrigSignature(const std::string& data, + const std::string& signatureFileContent, + const std::string& pubKeyBase64); + // ── Background worker ──────────────────────────────────────────────────────── class XmrigUpdater { diff --git a/src/util/xmrig_updater_core.cpp b/src/util/xmrig_updater_core.cpp index 24f4813..cf9b0ba 100644 --- a/src/util/xmrig_updater_core.cpp +++ b/src/util/xmrig_updater_core.cpp @@ -13,6 +13,7 @@ #include #include +#include #include using json = nlohmann::json; @@ -144,5 +145,56 @@ std::vector xmrigExtractBasenames(const std::string& platformToken) return {"xmrig"}; } +int selectXmrigSignatureAsset(const XmrigRelease& release, const std::string& archiveName) +{ + if (archiveName.empty()) return -1; + for (std::size_t i = 0; i < release.assets.size(); ++i) { + const std::string& n = release.assets[i].name; + if (n == archiveName + ".sig" || n == archiveName + ".minisig") + return static_cast(i); + } + return -1; +} + +namespace { +// Decode base64 (whitespace tolerated) into exactly `expected` bytes; false on any mismatch. +bool base64ToFixed(const std::string& b64, unsigned char* out, std::size_t expected) +{ + std::size_t binLen = 0; + const char* end = nullptr; + if (sodium_base642bin(out, expected, b64.data(), b64.size(), " \t\r\n", + &binLen, &end, sodium_base64_VARIANT_ORIGINAL) != 0) + return false; + return binLen == expected; +} +} // namespace + +bool verifyXmrigSignature(const std::string& data, + const std::string& signatureFileContent, + const std::string& pubKeyBase64) +{ + if (sodium_init() < 0) return false; + + 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(); + + 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; + } + + return crypto_sign_verify_detached( + sig, reinterpret_cast(data.data()), data.size(), pubKey) == 0; +} + } // namespace util } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index c8293a5..62dfa76 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -48,6 +48,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -4042,6 +4043,52 @@ void testXmrigSha256AndBasenames() != winNames.end()); } +void testXmrigSignatureAssetSelection() +{ + using namespace dragonx::util; + XmrigRelease rel; rel.ok = true; rel.tag = "v1"; + rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100}); + EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1); // none published + rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64}); + EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0); + EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1); +} + +void testXmrigSignatureVerify() +{ + using namespace dragonx::util; + EXPECT_TRUE(sodium_init() >= 0); + unsigned char pk[crypto_sign_PUBLICKEYBYTES], sk[crypto_sign_SECRETKEYBYTES]; + crypto_sign_keypair(pk, sk); + const std::string data = "drg-xmrig archive payload bytes for signing test"; + unsigned char sig[crypto_sign_BYTES]; + crypto_sign_detached(sig, nullptr, + reinterpret_cast(data.data()), data.size(), sk); + auto b64 = [](const unsigned char* b, std::size_t n) { + std::vector buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL)); + sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL); + return std::string(buf.data()); + }; + const std::string pkB64 = b64(pk, sizeof(pk)); + const std::string sigB64 = b64(sig, sizeof(sig)); + + // Valid base64 signature verifies; a raw 64-byte signature is also accepted. + EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64)); + EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast(sig), + crypto_sign_BYTES), pkB64)); + // Whitespace around the base64 signature is tolerated. + EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64)); + + // 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]; + crypto_sign_keypair(pk2, sk2); + EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2)))); + EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64)); + EXPECT_FALSE(verifyXmrigSignature(data, sigB64, "")); + EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64)); +} + // Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so // CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the // full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path. @@ -4138,6 +4185,8 @@ int main() testXmrigAssetSelection(); testXmrigChecksumParsing(); testXmrigSha256AndBasenames(); + testXmrigSignatureAssetSelection(); + testXmrigSignatureVerify(); testXmrigLiveInstall(); testGeneratedResourceBehavior();