fix(security): apply audit remediations to dev (ported + dev-only)

Dev-branch audit (docs/_archive/security-audit-dev-2026-07-22.md) re-found the
master issues (absent on dev) plus new ones in dev-only code. Applied here;
full-node build + test suite green; the subtle fixes were adversarially re-verified.

Ported from the master remediation (adapted to dev's code):
- bootstrap: reject zip-slip / path-traversal archive members (isSafeArchivePath)
  before writing (S2-1). (dev already fail-closes on a missing checksum.)
- xmrig updater: fail closed when a signature is required but no key is pinned (F1-1).
- http_download.httpGetString: 16 MiB hard cap + MAXFILESIZE on the shared
  metadata/price fetch (F1-2 / caps the updater + exchange paths at one site).
- rpc_client: explicit SSL_VERIFYPEER/VERIFYHOST (F4-1) and a 256 MiB response
  cap in WriteCallback (F4-3).
- lite_connection_service: reject remote http:// lite servers, loopback only (L1-1).
  Loopback is matched by a strict dotted-decimal 127.0.0.0/8 check (not a
  startsWith("127.") prefix, which would wrongly accept 127.0.0.1.evil.com), with
  userinfo/fragment stripping.
- lite controller: propagate encrypt/decrypt save() failure instead of reporting
  success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing secrets (F5-2).
- app: clear the copied secret from the OS clipboard on shutdown (F3b-1).
- export_transactions: neutralize CSV/spreadsheet formula injection (F13-1).
- build pipeline: build-from-source lite backend + remove the self-attested
  CMake signature gate (F15-1); pinned+verified appimagetool (F15-3/4);
  verified Sapling params in setup.sh (F15-6); build.sh exits 0 on success.
  (F14-1 empty-quoted-arg and F8-2 NUL-termination were already fixed on dev.)

Dev-only findings:
- rpc_client.callRaw: scrub the raw buffer + parsed tree (templated scrubJsonSecrets
  for ordered_json) so console dumpprivkey/z_exportkey keys don't linger in freed
  heap (N1-1).
- seed_wallet_creator: wipe the exported mnemonic on the failure path so a discarded
  failed result never carries a live seed (W1-2).
- export_all_keys: write the plaintext key dump 0600 + atomically via
  writeFileAtomically (U1-2).
- chat_database: restrict chat_messages.sqlite and its WAL/SHM sidecars to owner-only (C3-1).

Not done (need a decision, documented in the report):
- Chat header metadata (cid/z/p) rides outside the AEAD (C1/C2) — binding it is a
  wire-protocol change requiring SilentDragonXLite interop review.
- Bootstrap lacks an offline-rooted signature (S2-2 residual) — needs signing infra.
- Console scrollback retains console-typed key-export output in plaintext (N1-1
  residual) — inherent to an echoing console; would need output redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 12:02:50 -05:00
parent e1870c3b23
commit bcee4bfe72
18 changed files with 289 additions and 155 deletions

View File

@@ -53,7 +53,6 @@ set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
litelib_wallet_exists
litelib_initialize_new
@@ -126,31 +125,12 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
endif()
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
endif()
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
endif()
endif()
# Note (F15-1): the former signature-metadata gate was removed. It trusted a
# "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
# self-attested with no cryptographic check (the "verified" SHA was just the artifact's
# own SHA). The trust root is now build-from-source: that script builds the backend from
# the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
# is the one built from reviewed source. The required-symbol inventory check above stays.
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
set_target_properties(dragonx_lite_backend PROPERTIES
@@ -1204,5 +1184,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
message(STATUS " Lite trust: built-from-source (vendored third_party/silentdragonxlite)")
message(STATUS "")

View File

@@ -478,18 +478,28 @@ APPRUN
done
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
# appimagetool
# appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a MOVING build fetched over the network and run on the release
# builder; a compromised/MITM'd artifact would execute here. Verify, or refuse to package.
local APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
local APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
local APPIMAGETOOL=""
if command -v appimagetool &>/dev/null; then
APPIMAGETOOL="appimagetool"
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
info "Downloading appimagetool ..."
wget -q -O "$bd/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
local at="$bd/appimagetool-x86_64.AppImage"
# Re-verify any cached copy too; a stale unverified download must not be trusted.
if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
info "Downloading appimagetool 1.9.0 (pinned) ..."
wget -q -O "$at" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
err "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$at"
return 1
fi
chmod +x "$at"
fi
APPIMAGETOOL="$at"
fi
local ARCH
@@ -1311,3 +1321,9 @@ if $DO_LINUX || $DO_WIN || $DO_MAC; then
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
fi
# Reaching here means the build completed (real failures exit 1 at their point of failure).
# Exit 0 explicitly: the final `[[ -d release/mac ]] && echo` above returns non-zero on a
# non-mac build — and since set -e exempts the left side of an &&, that status would otherwise
# become the script's exit code and make a successful build report failure (e.g. to CI).
exit 0

View File

@@ -67,25 +67,9 @@ Options:
--backend-dir PATH SilentDragonXLite/lib source directory.
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
--out-dir PATH Output directory for copied artifact and metadata.
--artifact PATH Inventory an existing artifact instead of building.
--no-build Do not run cargo; requires --artifact.
--reproducible Add deterministic Rust path remaps for clean builds.
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
--builder NAME Redacted builder/provenance label. Default: local.
--signature-required Fail if verified signature metadata is not supplied.
--signature-file PATH Existing sidecar signature file to record.
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
--signature-verification-tool T Verification tool and version used by the release builder.
--signature-verification-command C
Verification command already run by the release builder.
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
--signature-certificate-identity ID
Reviewed certificate identity, when applicable.
--signature-certificate-issuer I
Reviewed certificate issuer, when applicable.
--signature-transparency-log-url URL
Transparency log entry, when applicable.
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
-j, --jobs N Cargo parallel jobs.
--cargo-arg ARG Extra argument forwarded to cargo build.
-h, --help Show this help.
@@ -95,9 +79,13 @@ Outputs:
<out>/<platform>/lite-backend-symbols.txt
<out>/<platform>/lite-backend-artifact-manifest.json
The script captures symbols, checksums, and optional read-only signature
verification metadata only. It does not load the library, resolve function
pointers, call SDXL, sign, upload, or publish artifacts.
The lite backend is always built from the vendored in-tree source
(third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
and self-attested signature metadata are NOT accepted (F15-1) — the previous
scheme only recorded an unverified "verified" claim. The script captures the
freshly-built artifact's symbols and checksum, and records build provenance.
It does not load the library, resolve function pointers, call SDXL, sign,
upload, or publish artifacts.
EOF
}
@@ -166,15 +154,8 @@ while [[ $# -gt 0 ]]; do
OUT_DIR="$(absolute_path "$2")"
shift 2
;;
--artifact)
[[ $# -ge 2 ]] || die "--artifact requires a value"
ARTIFACT_PATH="$(absolute_path "$2")"
BUILD_ARTIFACT=false
shift 2
;;
--no-build)
BUILD_ARTIFACT=false
shift
--artifact|--no-build)
die "$1 was removed (F15-1): the lite backend must be built from the vendored in-tree source (third_party/silentdragonxlite); prebuilt artifacts are no longer accepted."
;;
--reproducible)
REPRODUCIBLE=true
@@ -191,54 +172,11 @@ while [[ $# -gt 0 ]]; do
BUILDER="$2"
shift 2
;;
--signature-required)
SIGNATURE_REQUIRED=true
shift
;;
--signature-file|--signature-path)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_FILE="$(absolute_path "$2")"
shift 2
;;
--signature-format)
[[ $# -ge 2 ]] || die "--signature-format requires a value"
SIGNATURE_FORMAT="$2"
shift 2
;;
--signature-verification-tool|--signature-tool)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_VERIFICATION_TOOL="$2"
shift 2
;;
--signature-verification-command)
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
SIGNATURE_VERIFICATION_COMMAND="$2"
shift 2
;;
--signature-key-fingerprint)
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
SIGNATURE_KEY_FINGERPRINT="$2"
shift 2
;;
--signature-certificate-identity)
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
SIGNATURE_CERTIFICATE_IDENTITY="$2"
shift 2
;;
--signature-certificate-issuer)
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
SIGNATURE_CERTIFICATE_ISSUER="$2"
shift 2
;;
--signature-transparency-log-url)
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
shift 2
;;
--signature-verified-sha256)
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
SIGNATURE_VERIFIED_SHA256="$2"
shift 2
--signature-required|--signature-file|--signature-path|--signature-format|\
--signature-verification-tool|--signature-tool|--signature-verification-command|\
--signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
--signature-transparency-log-url|--signature-verified-sha256)
die "signature-attestation flags were removed (F15-1): they recorded a self-attested \"verified\" claim without running any cryptographic verifier. The lite backend is built from the vendored in-tree source, which is the trust root."
;;
-j|--jobs)
[[ $# -ge 2 ]] || die "--jobs requires a value"
@@ -766,6 +704,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
printf ' },\n'
printf ' "provenance": {\n'
printf ' "owner_ready": true,\n'
printf ' "built_from_source": true,\n'
printf ' "metadata_provided": true,\n'
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'

View File

@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
exit 1
fi
# Check for appimagetool
# Check for appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a moving, unverified network download that runs on the release
# builder; verify it or refuse to package.
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
APPIMAGETOOL=""
if command -v appimagetool &> /dev/null; then
APPIMAGETOOL="appimagetool"
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
print_status "Downloading appimagetool..."
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_status "Downloading appimagetool 1.9.0 (pinned)..."
wget -q -O "$AT" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_error "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$AT"
exit 1
fi
chmod +x "$AT"
fi
APPIMAGETOOL="$AT"
fi
print_status "Creating AppDir structure..."

View File

@@ -391,11 +391,26 @@ elif $SETUP_SAPLING; then
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
# Consensus-critical MPC parameters with fixed, well-known SHA-256 (identical across every
# Zcash-family node; also pinned in scripts/build-lite-backend-artifact.sh). z.cash is
# plain HTTPS with no signature, so verify the digest and refuse a tampered/corrupt file.
SPEND_SHA256="8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
OUTPUT_SHA256="2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" && \
ok "Downloaded sapling-spend.params"
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \
ok "Downloaded sapling-output.params"
if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
&& echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-spend.params"
else
rm -f "$PARAMS_DIR/sapling-spend.params"
err "sapling-spend.params download or SHA-256 verification failed — not installed"
fi
if curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" \
&& echo "${OUTPUT_SHA256} $PARAMS_DIR/sapling-output.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-output.params"
else
rm -f "$PARAMS_DIR/sapling-output.params"
err "sapling-output.params download or SHA-256 verification failed — not installed"
fi
fi
else
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"

View File

@@ -5320,6 +5320,11 @@ void App::renderLoadingOverlay(float contentH)
void App::shutdown()
{
// Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
// never fires if the user quits sooner, which would otherwise leave a key/seed resident.
// (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
clearSecretClipboardIfArmed();
// Clean up bootstrap if running
if (bootstrap_) {
bootstrap_->cancel();
@@ -5522,10 +5527,9 @@ void App::copySecretToClipboard(const std::string& secret)
ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f);
}
void App::pumpSecretClipboardClear()
void App::clearSecretClipboardIfArmed()
{
if (clipboard_clear_deadline_ <= 0.0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
if (clipboard_secret_hash_ == 0) return;
// Only clear if the clipboard STILL holds our secret (the user may have copied something else).
if (const char* cb = ImGui::GetClipboardText()) {
std::uint64_t h = 1469598103934665603ULL;
@@ -5536,6 +5540,13 @@ void App::pumpSecretClipboardClear()
clipboard_secret_hash_ = 0;
}
void App::pumpSecretClipboardClear()
{
if (clipboard_clear_deadline_ <= 0.0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
clearSecretClipboardIfArmed();
}
void App::maybeFinishTransactionSendProgress()
{
using Job = services::NetworkRefreshService::Job;

View File

@@ -573,6 +573,9 @@ public:
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear();
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
void clearSecretClipboardIfArmed();
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}

View File

@@ -228,6 +228,17 @@ bool ChatDatabase::ensureOpen()
exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL");
// C3-1: restrict the chat DB and its WAL/SHM sidecars to owner-only. sqlite creates them with
// umask-derived permissions (often world/group-readable); they hold per-row nonces + AEAD
// ciphertext of the user's messages. Best-effort (errors swallowed; a no-op-ish on Windows).
{
std::error_code perr;
const auto ownerOnly = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write;
std::filesystem::permissions(database_path_, ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-wal", ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-shm", ownerOnly, std::filesystem::perm_options::replace, perr);
}
if (!createSchema()) {
close();
return false;

View File

@@ -136,6 +136,14 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
}
}
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
if (!r.ok && !r.seedPhrase.empty()) {
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
r.seedPhrase.clear();
}
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect();
temp.stop(20000);

View File

@@ -208,19 +208,20 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try {
fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath);
std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath;
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
ofs << j.dump(4);
ofs.close();
#ifndef _WIN32
// 0600 permissions — only owner can read/write
// Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
// address, worker name). The file is still empty here, so the config is never
// world-readable — closing the window between creation and the previous post-write chmod.
chmod(outPath.c_str(), 0600);
#endif
ofs << j.dump(4);
ofs.close();
return true;
} catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what();

View File

@@ -25,9 +25,11 @@ namespace {
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
void scrubJsonSecrets(nlohmann::json& j) {
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
template <typename J>
void scrubJsonSecrets(J& j) {
if (j.is_string()) {
auto& s = j.get_ref<std::string&>();
auto& s = j.template get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el);
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb;
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
userp->append((char*)contents, totalSize);
return totalSize;
}
@@ -202,6 +208,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
@@ -508,14 +518,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
}
auto& result = oj["result"];
std::string out;
if (result.is_null()) {
return "null";
out = "null";
} else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>();
out = result.get<std::string>();
} else {
return result.dump(4);
out = result.dump(4);
}
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
// the caller's to manage.
scrubJsonSecrets(oj);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return out;
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)

View File

@@ -211,12 +211,11 @@ void ExportAllKeysDialog::render(App* app)
std::string filepath = configDir + "/" + filename;
bool writeOk = false;
if (exported > 0) {
std::ofstream file(filepath);
if (file.is_open()) {
file << keys;
file.close();
writeOk = true;
}
// Write the plaintext private keys 0600 + atomically (never
// world/group-readable, never a half-written file) — the same restricted
// atomic-write idiom the PIN vault uses. A default ofstream would create
// the key dump with umask-derived (often 0644) permissions.
writeOk = util::Platform::writeFileAtomically(filepath, keys, /*restrictPermissions=*/true);
}
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap

View File

@@ -36,21 +36,30 @@ static bool s_exporting = false;
// Helper to escape CSV field
static std::string escapeCSV(const std::string& field)
{
if (field.find(',') != std::string::npos ||
field.find('"') != std::string::npos ||
field.find('\n') != std::string::npos) {
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
std::string safe = field;
if (!safe.empty()) {
const char c0 = safe.front();
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
safe.insert(safe.begin(), '\'');
}
if (safe.find(',') != std::string::npos ||
safe.find('"') != std::string::npos ||
safe.find('\n') != std::string::npos) {
// Escape quotes and wrap in quotes
std::string escaped;
escaped.reserve(field.size() + 4);
escaped.reserve(safe.size() + 4);
escaped += '"';
for (char c : field) {
for (char c : safe) {
if (c == '"') escaped += "\"\"";
else escaped += c;
}
escaped += '"';
return escaped;
}
return field;
return safe;
}
void ExportTransactionsDialog::show()

View File

@@ -31,6 +31,31 @@ static bool endsWith(const std::string& s, const std::string& suffix) {
return s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
// Reject archive member names that would escape the extraction root (zip-slip).
// The snapshot legitimately carries sub-paths (blocks/, chainstate/), so — unlike the
// updaters, which flatten to baseName() — we keep the relative path but refuse any entry
// that is absolute, drive/UNC-rooted, or contains a ".." component.
static bool isSafeArchivePath(const std::string& name) {
if (name.empty()) return false;
std::string n = name;
std::replace(n.begin(), n.end(), '\\', '/'); // normalize Windows separators
if (n.front() == '/') return false; // absolute POSIX path
const char c0 = n[0];
if (n.size() >= 2 && n[1] == ':' &&
((c0 >= 'A' && c0 <= 'Z') || (c0 >= 'a' && c0 <= 'z')))
return false; // Windows drive letter (C:...)
size_t start = 0;
while (start <= n.size()) {
const size_t slash = n.find('/', start);
const std::string comp =
n.substr(start, slash == std::string::npos ? std::string::npos : slash - start);
if (comp == "..") return false; // path traversal component
if (slash == std::string::npos) break;
start = slash + 1;
}
return true;
}
static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb;
FILE* fp = static_cast<FILE*>(userp);
@@ -383,6 +408,16 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir)
std::string filename = stat.m_filename;
// *** SECURITY: reject zip-slip / path-traversal entries before building any path ***
// A legitimate snapshot from the project host never contains these; an entry that does
// indicates a malicious/corrupt archive, so abort rather than silently skip.
if (!isSafeArchivePath(filename)) {
DEBUG_LOGF("[Bootstrap] Unsafe archive path rejected: %s\n", filename.c_str());
setProgress(State::Failed, "Refusing to extract unsafe archive entry: " + filename);
mz_zip_reader_end(&zip);
return false;
}
// *** CRITICAL: Skip wallet.dat ***
if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) {
DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n");

View File

@@ -19,10 +19,19 @@ namespace util {
namespace {
// Cap for in-memory text/JSON responses (release metadata, price/candle data). Far above any
// legitimate body, but bounds memory if a hostile/MITM'd server streams an unbounded response.
constexpr std::size_t kMaxMetadataBytes = 16u * 1024 * 1024;
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
{
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
return size * nmemb;
auto* s = static_cast<std::string*>(userp);
const size_t n = size * nmemb;
// Hard cap: a chunked response omits Content-Length, so CURLOPT_MAXFILESIZE cannot catch it —
// aborting here (short count) makes curl fail the transfer instead of exhausting memory.
if (s->size() + n > kMaxMetadataBytes) return 0;
s->append(static_cast<char*>(contents), n);
return n;
}
size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp)
@@ -52,6 +61,7 @@ std::string httpGetString(const std::string& url, const char* logTag)
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, static_cast<curl_off_t>(kMaxMetadataBytes)); // reject oversized metadata
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode res = curl_easy_perform(curl);

View File

@@ -314,6 +314,12 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
return;
}
}
} else if (kXmrigRequireSignature) {
// Signatures are required but no key is pinned in this build: fail closed rather than
// silently downgrading to checksum-only (the checksum is same-origin as the archive).
fs::remove(zipPath, ec);
setProgress(State::Failed, "No signing key is pinned in this build — refusing to install.");
return;
}
}

View File

@@ -81,10 +81,56 @@ LiteConnectionSettings defaultLiteConnectionSettings()
return settings;
}
// Strict dotted-decimal check for the 127.0.0.0/8 loopback block: exactly four numeric octets
// (each 0-255) with the first equal to 127. This must NOT be a prefix match — "127.0.0.1.evil.com"
// and "127.evil.com" are attacker-controlled DNS names that a startsWith("127.") test would wrongly
// treat as loopback, reopening the plaintext-downgrade hole.
static bool isNumericIpv4Loopback(const std::string& h)
{
int parts = 0;
size_t start = 0;
while (true) {
const size_t dot = h.find('.', start);
const std::string seg = h.substr(start, dot == std::string::npos ? std::string::npos : dot - start);
if (seg.empty() || seg.size() > 3) return false;
int v = 0;
for (char c : seg) { if (c < '0' || c > '9') return false; v = v * 10 + (c - '0'); }
if (v > 255) return false;
if (parts == 0 && v != 127) return false; // 127.0.0.0/8 only
++parts;
if (dot == std::string::npos) break;
start = dot + 1;
}
return parts == 4;
}
static bool isLoopbackLiteHostSpec(const std::string& hostPort)
{
std::string h = hostPort;
const size_t term = h.find_first_of("/?#"); // strip path/query/fragment
if (term != std::string::npos) h = h.substr(0, term);
const size_t at = h.rfind('@'); // strip userinfo (user:pass@host)
if (at != std::string::npos) h = h.substr(at + 1);
if (!h.empty() && h.front() == '[') { // [::1]:port (bracketed IPv6)
const size_t close = h.find(']');
h = (close == std::string::npos) ? h : h.substr(1, close - 1);
} else { // host or host:port
const size_t colon = h.find(':');
if (colon != std::string::npos) h = h.substr(0, colon);
}
return h == "localhost" || h == "::1" || isNumericIpv4Loopback(h);
}
bool isLiteServerUrlUsable(const std::string& serverUrl)
{
const std::string normalized = liteTrimCopy(serverUrl);
return startsWith(normalized, "https://") || startsWith(normalized, "http://");
if (startsWith(normalized, "https://")) return true;
// SECURITY: plaintext http:// is a TLS downgrade for lightwalletd traffic (view keys,
// transactions, addresses). Permit it only for loopback (a local dev lightwalletd);
// reject remote plaintext servers instead of silently accepting the downgrade.
if (startsWith(normalized, "http://"))
return isLoopbackLiteHostSpec(normalized.substr(sizeof("http://") - 1));
return false;
}
bool isOfficialLiteServer(const std::string& serverUrl)

View File

@@ -1066,7 +1066,16 @@ LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
}
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet
if (out.ok) {
// Persist the now-encrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be unencrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet encrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out;
}
@@ -1084,7 +1093,16 @@ LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
}
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet
if (out.ok) {
// Persist the now-unencrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be encrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet decrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out;
}