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_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_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") 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 set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
litelib_wallet_exists litelib_wallet_exists
litelib_initialize_new litelib_initialize_new
@@ -126,31 +125,12 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}") 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}") message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
endif() endif()
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE) # Note (F15-1): the former signature-metadata gate was removed. It trusted a
if(NOT DRAGONX_LITE_BACKEND_MANIFEST) # "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST") # self-attested with no cryptographic check (the "verified" SHA was just the artifact's
endif() # own SHA). The trust root is now build-from-source: that script builds the backend from
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON) # the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status) # is the one built from reviewed source. The required-symbol inventory check above stays.
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()
add_library(dragonx_lite_backend UNKNOWN IMPORTED) add_library(dragonx_lite_backend UNKNOWN IMPORTED)
set_target_properties(dragonx_lite_backend PROPERTIES 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 lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}") message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}") 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 "") message(STATUS "")

View File

@@ -478,18 +478,28 @@ APPRUN
done done
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true [[ -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="" local APPIMAGETOOL=""
if command -v appimagetool &>/dev/null; then if command -v appimagetool &>/dev/null; then
APPIMAGETOOL="appimagetool" APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
else else
info "Downloading appimagetool ..." local at="$bd/appimagetool-x86_64.AppImage"
wget -q -O "$bd/appimagetool-x86_64.AppImage" \ # Re-verify any cached copy too; a stale unverified download must not be trusted.
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
chmod +x "$bd/appimagetool-x86_64.AppImage" info "Downloading appimagetool 1.9.0 (pinned) ..."
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage" 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 fi
local ARCH 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/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg" [[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
fi 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. --backend-dir PATH SilentDragonXLite/lib source directory.
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path. --silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
--out-dir PATH Output directory for copied artifact and metadata. --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. --reproducible Add deterministic Rust path remaps for clean builds.
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible. --remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
--builder NAME Redacted builder/provenance label. Default: local. --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. -j, --jobs N Cargo parallel jobs.
--cargo-arg ARG Extra argument forwarded to cargo build. --cargo-arg ARG Extra argument forwarded to cargo build.
-h, --help Show this help. -h, --help Show this help.
@@ -95,9 +79,13 @@ Outputs:
<out>/<platform>/lite-backend-symbols.txt <out>/<platform>/lite-backend-symbols.txt
<out>/<platform>/lite-backend-artifact-manifest.json <out>/<platform>/lite-backend-artifact-manifest.json
The script captures symbols, checksums, and optional read-only signature The lite backend is always built from the vendored in-tree source
verification metadata only. It does not load the library, resolve function (third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
pointers, call SDXL, sign, upload, or publish 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 EOF
} }
@@ -166,15 +154,8 @@ while [[ $# -gt 0 ]]; do
OUT_DIR="$(absolute_path "$2")" OUT_DIR="$(absolute_path "$2")"
shift 2 shift 2
;; ;;
--artifact) --artifact|--no-build)
[[ $# -ge 2 ]] || die "--artifact requires a value" 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."
ARTIFACT_PATH="$(absolute_path "$2")"
BUILD_ARTIFACT=false
shift 2
;;
--no-build)
BUILD_ARTIFACT=false
shift
;; ;;
--reproducible) --reproducible)
REPRODUCIBLE=true REPRODUCIBLE=true
@@ -191,54 +172,11 @@ while [[ $# -gt 0 ]]; do
BUILDER="$2" BUILDER="$2"
shift 2 shift 2
;; ;;
--signature-required) --signature-required|--signature-file|--signature-path|--signature-format|\
SIGNATURE_REQUIRED=true --signature-verification-tool|--signature-tool|--signature-verification-command|\
shift --signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
;; --signature-transparency-log-url|--signature-verified-sha256)
--signature-file|--signature-path) 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."
[[ $# -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
;; ;;
-j|--jobs) -j|--jobs)
[[ $# -ge 2 ]] || die "--jobs requires a value" [[ $# -ge 2 ]] || die "--jobs requires a value"
@@ -766,6 +704,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
printf ' },\n' printf ' },\n'
printf ' "provenance": {\n' printf ' "provenance": {\n'
printf ' "owner_ready": true,\n' printf ' "owner_ready": true,\n'
printf ' "built_from_source": true,\n'
printf ' "metadata_provided": true,\n' printf ' "metadata_provided": true,\n'
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n' printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_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 exit 1
fi 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="" APPIMAGETOOL=""
if command -v appimagetool &> /dev/null; then if command -v appimagetool &> /dev/null; then
APPIMAGETOOL="appimagetool" APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
else else
print_status "Downloading appimagetool..." AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \ if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" print_status "Downloading appimagetool 1.9.0 (pinned)..."
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage" wget -q -O "$AT" "$APPIMAGETOOL_URL"
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage" 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 fi
print_status "Creating AppDir structure..." print_status "Creating AppDir structure..."

View File

@@ -391,11 +391,26 @@ elif $SETUP_SAPLING; then
SPEND_URL="https://z.cash/downloads/sapling-spend.params" SPEND_URL="https://z.cash/downloads/sapling-spend.params"
OUTPUT_URL="https://z.cash/downloads/sapling-output.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" && \ if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
ok "Downloaded sapling-spend.params" && echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \ ok "Downloaded + verified sapling-spend.params"
ok "Downloaded sapling-output.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 fi
else else
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)" 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() 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 // Clean up bootstrap if running
if (bootstrap_) { if (bootstrap_) {
bootstrap_->cancel(); 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); 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 (clipboard_secret_hash_ == 0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
// Only clear if the clipboard STILL holds our secret (the user may have copied something else). // Only clear if the clipboard STILL holds our secret (the user may have copied something else).
if (const char* cb = ImGui::GetClipboardText()) { if (const char* cb = ImGui::GetClipboardText()) {
std::uint64_t h = 1469598103934665603ULL; std::uint64_t h = 1469598103934665603ULL;
@@ -5536,6 +5540,13 @@ void App::pumpSecretClipboardClear()
clipboard_secret_hash_ = 0; 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() void App::maybeFinishTransactionSendProgress()
{ {
using Job = services::NetworkRefreshService::Job; using Job = services::NetworkRefreshService::Job;

View File

@@ -573,6 +573,9 @@ public:
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear. // plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret); void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear(); 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 { bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions); return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
} }

View File

@@ -228,6 +228,17 @@ bool ChatDatabase::ensureOpen()
exec("PRAGMA journal_mode=WAL"); exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL"); 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()) { if (!createSchema()) {
close(); close();
return false; 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). // 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect(); cli.disconnect();
temp.stop(20000); temp.stop(20000);

View File

@@ -208,19 +208,20 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try { try {
fs::create_directories(fs::path(outPath).parent_path()); fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath); std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) { if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath; last_error_ = "Cannot write xmrig config: " + outPath;
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
ofs << j.dump(4);
ofs.close();
#ifndef _WIN32 #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); chmod(outPath.c_str(), 0600);
#endif #endif
ofs << j.dump(4);
ofs.close();
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what(); 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 // 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. // 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()) { 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()); if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) { } else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el); for (auto& el : j) scrubJsonSecrets(el);
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data // Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) { static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb; 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); userp->append((char*)contents, totalSize);
return 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). // budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L; const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout); 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 // 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 // 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"]; auto& result = oj["result"];
std::string out;
if (result.is_null()) { if (result.is_null()) {
return "null"; out = "null";
} else if (result.is_string()) { } else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed // Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>(); out = result.get<std::string>();
} else { } 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) 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; std::string filepath = configDir + "/" + filename;
bool writeOk = false; bool writeOk = false;
if (exported > 0) { if (exported > 0) {
std::ofstream file(filepath); // Write the plaintext private keys 0600 + atomically (never
if (file.is_open()) { // world/group-readable, never a half-written file) — the same restricted
file << keys; // atomic-write idiom the PIN vault uses. A default ofstream would create
file.close(); // the key dump with umask-derived (often 0644) permissions.
writeOk = true; 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 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 // Helper to escape CSV field
static std::string escapeCSV(const std::string& field) static std::string escapeCSV(const std::string& field)
{ {
if (field.find(',') != std::string::npos || // Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
field.find('"') != std::string::npos || // tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
field.find('\n') != std::string::npos) { // 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 // Escape quotes and wrap in quotes
std::string escaped; std::string escaped;
escaped.reserve(field.size() + 4); escaped.reserve(safe.size() + 4);
escaped += '"'; escaped += '"';
for (char c : field) { for (char c : safe) {
if (c == '"') escaped += "\"\""; if (c == '"') escaped += "\"\"";
else escaped += c; else escaped += c;
} }
escaped += '"'; escaped += '"';
return escaped; return escaped;
} }
return field; return safe;
} }
void ExportTransactionsDialog::show() 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; 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) { static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb; size_t total = size * nmemb;
FILE* fp = static_cast<FILE*>(userp); 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; 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 *** // *** CRITICAL: Skip wallet.dat ***
if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) { if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) {
DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n"); DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n");

View File

@@ -19,10 +19,19 @@ namespace util {
namespace { 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) 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); auto* s = static_cast<std::string*>(userp);
return size * nmemb; 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) 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_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); 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_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode res = curl_easy_perform(curl); const CURLcode res = curl_easy_perform(curl);

View File

@@ -314,6 +314,12 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
return; 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; 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) bool isLiteServerUrlUsable(const std::string& serverUrl)
{ {
const std::string normalized = liteTrimCopy(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) bool isOfficialLiteServer(const std::string& serverUrl)

View File

@@ -1066,7 +1066,16 @@ LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
} }
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase)); out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
secureWipeLiteSecret(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; return out;
} }
@@ -1084,7 +1093,16 @@ LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
} }
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase)); out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
secureWipeLiteSecret(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; return out;
} }