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 "<archive>.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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 20:29:49 -05:00
parent 98e0cce8ec
commit 8765fdf362
5 changed files with 225 additions and 1 deletions

View File

@@ -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

View File

@@ -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 "<archive-name>.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<std::string> xmrigExtractBasenames(const std::string& platformToken);
// Index of the detached-signature asset for a given archive (name "<archive>.sig" or
// "<archive>.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 {

View File

@@ -13,6 +13,7 @@
#include <algorithm>
#include <cctype>
#include <cstring>
#include <sstream>
using json = nlohmann::json;
@@ -144,5 +145,56 @@ std::vector<std::string> 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<int>(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<const unsigned char*>(data.data()), data.size(), pubKey) == 0;
}
} // namespace util
} // namespace dragonx