diff --git a/src/app_security.cpp b/src/app_security.cpp index 2bea442..ea835bd 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -240,22 +240,13 @@ private: // daemon off the main thread (to avoid stalling the UI), or ask the user to // restart an external daemon. Shared by encryptWalletWithPassphrase() and // processDeferredEncryption(); must be called on the main thread. -// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is -// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02) +// Best-effort secure delete (overwrite in place, fsync, unlink) of a plaintext key export, so a full +// cleartext dump of every private key is not left readable on disk. Idempotent + error-tolerant +// (safe on a missing/locked file). (H-02) void App::scrubAndRemoveExport(const std::string& path) { if (path.empty()) return; - std::error_code ec; - const auto sz = std::filesystem::file_size(path, ec); - if (!ec && sz > 0) { - std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out); - if (scrub) { - const std::vector zeros(static_cast(sz), 0); - scrub.write(zeros.data(), static_cast(sz)); - scrub.flush(); - } - } - std::filesystem::remove(path, ec); + util::Platform::secureWipeAndRemove(path); } // Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them diff --git a/src/util/platform.cpp b/src/util/platform.cpp index 91d62e4..9a284e1 100644 --- a/src/util/platform.cpp +++ b/src/util/platform.cpp @@ -300,6 +300,58 @@ bool Platform::deleteFile(const std::string& path) #endif } +bool Platform::secureWipeAndRemove(const std::string& path) +{ + // Best-effort; SSD wear-levelling and CoW/journaling filesystems may retain copies. + if (path.empty()) return false; + static constexpr size_t kChunk = 64 * 1024; + const std::string buf(kChunk, static_cast(0xAB)); +#ifdef _WIN32 + HANDLE h = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, nullptr); + if (h != INVALID_HANDLE_VALUE) { + LARGE_INTEGER sz; + if (GetFileSizeEx(h, &sz) && sz.QuadPart > 0) { + long long left = sz.QuadPart; + while (left > 0) { + DWORD n = static_cast(left < (long long)kChunk ? left : (long long)kChunk); + DWORD written = 0; + if (!WriteFile(h, buf.data(), n, &written, nullptr) || written == 0) break; + left -= written; + } + FlushFileBuffers(h); + } + CloseHandle(h); + } + DeleteFileA(path.c_str()); + return GetFileAttributesA(path.c_str()) == INVALID_FILE_ATTRIBUTES; +#else + int fd = ::open(path.c_str(), O_WRONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd >= 0) { + struct stat st; + if (::fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0) { + off_t off = 0; + const off_t total = st.st_size; + while (off < total) { + size_t n = static_cast(total - off < (off_t)kChunk ? total - off : (off_t)kChunk); + ssize_t w = ::pwrite(fd, buf.data(), n, off); + if (w <= 0) break; + off += w; + } +#ifdef __APPLE__ + if (::fcntl(fd, F_FULLFSYNC) != 0) ::fsync(fd); +#else + ::fsync(fd); +#endif + } + ::close(fd); + } + ::remove(path.c_str()); + struct stat st2; + return ::lstat(path.c_str(), &st2) != 0 && errno == ENOENT; +#endif +} + bool Platform::writeFileAtomically(const std::string& path, const std::string& content, bool restrictPermissions) diff --git a/src/util/platform.h b/src/util/platform.h index baad2ce..84742ec 100644 --- a/src/util/platform.h +++ b/src/util/platform.h @@ -84,6 +84,15 @@ public: */ static bool deleteFile(const std::string& path); + /** + * @brief Best-effort secure delete: overwrite the file in place, flush, then unlink. + * + * Never truncates first (that would free the original blocks). Always attempts the unlink. + * SSD wear-levelling and CoW/journaling filesystems may retain copies. + * @return true when the file no longer exists + */ + static bool secureWipeAndRemove(const std::string& path); + /** * @brief Write a file atomically and durably. * diff --git a/src/util/secure_vault.cpp b/src/util/secure_vault.cpp index 381ce65..c40d29b 100644 --- a/src/util/secure_vault.cpp +++ b/src/util/secure_vault.cpp @@ -15,11 +15,6 @@ #include #include "../util/logger.h" -#ifndef _WIN32 -#include -#include -#endif - namespace fs = std::filesystem; namespace dragonx { @@ -260,32 +255,10 @@ bool SecureVault::changePin(const std::string& oldPin, const std::string& newPin bool SecureVault::removeVault() { std::string vaultPath = getVaultPath(); std::error_code ec; - if (fs::exists(vaultPath, ec)) { - // Overwrite with zeros before removing (best-effort secure delete) - { - std::ifstream probe(vaultPath, std::ios::binary | std::ios::ate); - size_t sz = probe.is_open() ? (size_t)probe.tellg() : 0; - probe.close(); - if (sz > 0) { - std::vector zeros(sz, 0); - std::ofstream zap(vaultPath, std::ios::binary); - if (zap.is_open()) { - zap.write(reinterpret_cast(zeros.data()), sz); - zap.flush(); - zap.close(); - // Force the zeros to stable storage before unlinking — otherwise the - // write may never leave the OS cache (best-effort; not a guarantee on - // CoW/journaling filesystems or wear-leveling SSDs). -#ifndef _WIN32 - int fd = ::open(vaultPath.c_str(), O_WRONLY); - if (fd >= 0) { ::fsync(fd); ::close(fd); } -#endif - } - } - } - return fs::remove(vaultPath, ec); - } - return true; // Didn't exist — that's fine + if (!fs::exists(vaultPath, ec)) return true; // Didn't exist — that's fine + // Overwrite in place + fsync, then unlink (best-effort secure delete). + util::Platform::secureWipeAndRemove(vaultPath); + return !fs::exists(vaultPath, ec); } } // namespace util diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index bf65b97..fbfaa2d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -815,6 +815,46 @@ void testClassifyRpcFailure() EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull)); } +void testSecureWipeAndRemove() +{ + namespace fs = std::filesystem; + using dragonx::util::Platform; + std::error_code ec; + const fs::path dir = fs::temp_directory_path(ec) / "od_secwipe_test"; + fs::create_directories(dir, ec); + const std::string f = (dir / "victim.bin").string(); + { + std::ofstream o(f, std::ios::binary); + const std::string blob(200 * 1024, static_cast(0xAB)); + o.write(blob.data(), static_cast(blob.size())); + } + EXPECT_TRUE(fs::exists(f, ec)); + { + std::ifstream hold(f, std::ios::binary); // second handle open across the wipe + EXPECT_TRUE(Platform::secureWipeAndRemove(f)); + } + EXPECT_FALSE(fs::exists(f, ec)); + EXPECT_TRUE(Platform::secureWipeAndRemove(f)); // missing path +#ifndef _WIN32 + const std::string target = (dir / "target.bin").string(); + const std::string link = (dir / "link.bin").string(); + const std::string payload(4096, 'Z'); + { + std::ofstream o(target, std::ios::binary); + o.write(payload.data(), static_cast(payload.size())); + } + fs::create_symlink(target, link, ec); + if (!ec) { + EXPECT_TRUE(Platform::secureWipeAndRemove(link)); + EXPECT_FALSE(fs::is_symlink(link, ec)); + std::ifstream in(target, std::ios::binary); + std::string got((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + EXPECT_TRUE(got == payload); // target untouched + } +#endif + fs::remove_all(dir, ec); +} + void testClassifyUnlockFailure() { using dragonx::rpc::RpcError; @@ -18213,6 +18253,7 @@ int main() testConnectionConfig(); testClassifyRpcFailure(); testClassifyUnlockFailure(); + testSecureWipeAndRemove(); testFormatHttpError(); testRpcFailureKindOf(); testPaymentUri();