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:
72
scripts/sign-xmrig-release.sh
Executable file
72
scripts/sign-xmrig-release.sh
Executable file
@@ -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
|
||||||
|
# <name>.zip this produces <name>.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] # -> <prefix>.ed25519.{key,pub.b64}
|
||||||
|
# scripts/sign-xmrig-release.sh sign <secret.key> <file>... # -> <file>.sig per file
|
||||||
|
# scripts/sign-xmrig-release.sh pubkey <secret.key> # 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 <secret.key> <file>..."
|
||||||
|
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 <secret.key>"
|
||||||
|
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 <secret.key> <file>... | pubkey <secret.key>}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -264,7 +264,10 @@ void XmrigUpdater::runInstall(std::string targetDir)
|
|||||||
}
|
}
|
||||||
if (cancel_requested_) { fs::remove(zipPath, ec); setProgress(State::Failed, "Cancelled."); return; }
|
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…");
|
setProgress(State::Verifying, "Verifying download…");
|
||||||
{
|
{
|
||||||
std::ifstream f(zipPath, std::ios::binary);
|
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.");
|
setProgress(State::Failed, "Could not read the downloaded archive.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2a. SHA-256 (integrity / transit corruption).
|
||||||
const std::string actual = sha256Hex(bytes.data(), bytes.size());
|
const std::string actual = sha256Hex(bytes.data(), bytes.size());
|
||||||
const auto it = checksums.find(asset.name);
|
const auto it = checksums.find(asset.name);
|
||||||
if (it == checksums.end()) {
|
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).");
|
setProgress(State::Failed, "Archive checksum mismatch — refusing to install (possible tampering).");
|
||||||
return;
|
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
|
// 3. Extract the wanted binaries (flatten the versioned subdir), verifying the miner binary's
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ struct XmrigRelease {
|
|||||||
std::string error;
|
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) ───────────────────────────────────────
|
// ── Pure helpers (no I/O; unit-tested) ───────────────────────────────────────
|
||||||
|
|
||||||
// Parse the Gitea GET /releases/latest JSON into an XmrigRelease (ok=false + error on failure).
|
// 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.
|
// {"xmrig.exe", "WinRing0x64.sys"} on Windows. First entry is always the miner binary.
|
||||||
std::vector<std::string> xmrigExtractBasenames(const std::string& platformToken);
|
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 ────────────────────────────────────────────────────────
|
// ── Background worker ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class XmrigUpdater {
|
class XmrigUpdater {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstring>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
@@ -144,5 +145,56 @@ std::vector<std::string> xmrigExtractBasenames(const std::string& platformToken)
|
|||||||
return {"xmrig"};
|
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 util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
@@ -4042,6 +4043,52 @@ void testXmrigSha256AndBasenames()
|
|||||||
!= winNames.end());
|
!= 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<const unsigned char*>(data.data()), data.size(), sk);
|
||||||
|
auto b64 = [](const unsigned char* b, std::size_t n) {
|
||||||
|
std::vector<char> 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<const char*>(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
|
// 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
|
// 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.
|
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
|
||||||
@@ -4138,6 +4185,8 @@ int main()
|
|||||||
testXmrigAssetSelection();
|
testXmrigAssetSelection();
|
||||||
testXmrigChecksumParsing();
|
testXmrigChecksumParsing();
|
||||||
testXmrigSha256AndBasenames();
|
testXmrigSha256AndBasenames();
|
||||||
|
testXmrigSignatureAssetSelection();
|
||||||
|
testXmrigSignatureVerify();
|
||||||
testXmrigLiveInstall();
|
testXmrigLiveInstall();
|
||||||
testGeneratedResourceBehavior();
|
testGeneratedResourceBehavior();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user