fix(security): overwrite key/PIN files with fsync before deleting them

Two secure-delete paths overwrote a sensitive file and then unlinked it,
but neither reliably forced the overwrite to stable storage. The
plaintext key export used a buffered stream with only a flush, and the
PIN vault removal opened a second descriptor to fsync only on non-
Windows. On a crash, or on Windows, the zeroing write could stay in the
OS cache and the original bytes could survive on disk.

Both now go through one shared best-effort secure-delete helper:

- It overwrites the file in place in chunks, never truncating first, so
  the original blocks are the ones rewritten.
- It flushes the data to the device before unlinking (full fsync on
  macOS, write-through on Windows), then removes the file.
- It is safe on a missing file and reports whether the file is gone.

This is still best-effort: SSD wear-levelling and copy-on-write or
journaling filesystems may retain copies.

Tests exercise the overwrite-and-remove helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:24:12 -05:00
co-authored by Claude Opus 4.8
parent 63a422a615
commit 2a535c844d
5 changed files with 110 additions and 44 deletions
+4 -13
View File
@@ -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<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(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
+52
View File
@@ -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<char>(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<DWORD>(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<size_t>(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)
+9
View File
@@ -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.
*
+4 -31
View File
@@ -15,11 +15,6 @@
#include <algorithm>
#include "../util/logger.h"
#ifndef _WIN32
#include <fcntl.h>
#include <unistd.h>
#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<uint8_t> zeros(sz, 0);
std::ofstream zap(vaultPath, std::ios::binary);
if (zap.is_open()) {
zap.write(reinterpret_cast<const char*>(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
+41
View File
@@ -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<char>(0xAB));
o.write(blob.data(), static_cast<std::streamsize>(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<std::streamsize>(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<char>(in)), std::istreambuf_iterator<char>());
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();