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:
+4
-13
@@ -240,22 +240,13 @@ private:
|
|||||||
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
||||||
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
||||||
// processDeferredEncryption(); must be called on the main thread.
|
// 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
|
// Best-effort secure delete (overwrite in place, fsync, unlink) of a plaintext key export, so a full
|
||||||
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
|
// 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)
|
void App::scrubAndRemoveExport(const std::string& path)
|
||||||
{
|
{
|
||||||
if (path.empty()) return;
|
if (path.empty()) return;
|
||||||
std::error_code ec;
|
util::Platform::secureWipeAndRemove(path);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||||
|
|||||||
@@ -300,6 +300,58 @@ bool Platform::deleteFile(const std::string& path)
|
|||||||
#endif
|
#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,
|
bool Platform::writeFileAtomically(const std::string& path,
|
||||||
const std::string& content,
|
const std::string& content,
|
||||||
bool restrictPermissions)
|
bool restrictPermissions)
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool deleteFile(const std::string& path);
|
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.
|
* @brief Write a file atomically and durably.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,11 +15,6 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
|
|
||||||
#ifndef _WIN32
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
@@ -260,32 +255,10 @@ bool SecureVault::changePin(const std::string& oldPin, const std::string& newPin
|
|||||||
bool SecureVault::removeVault() {
|
bool SecureVault::removeVault() {
|
||||||
std::string vaultPath = getVaultPath();
|
std::string vaultPath = getVaultPath();
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (fs::exists(vaultPath, ec)) {
|
if (!fs::exists(vaultPath, ec)) return true; // Didn't exist — that's fine
|
||||||
// Overwrite with zeros before removing (best-effort secure delete)
|
// Overwrite in place + fsync, then unlink (best-effort secure delete).
|
||||||
{
|
util::Platform::secureWipeAndRemove(vaultPath);
|
||||||
std::ifstream probe(vaultPath, std::ios::binary | std::ios::ate);
|
return !fs::exists(vaultPath, ec);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
|
|||||||
@@ -815,6 +815,46 @@ void testClassifyRpcFailure()
|
|||||||
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
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()
|
void testClassifyUnlockFailure()
|
||||||
{
|
{
|
||||||
using dragonx::rpc::RpcError;
|
using dragonx::rpc::RpcError;
|
||||||
@@ -18213,6 +18253,7 @@ int main()
|
|||||||
testConnectionConfig();
|
testConnectionConfig();
|
||||||
testClassifyRpcFailure();
|
testClassifyRpcFailure();
|
||||||
testClassifyUnlockFailure();
|
testClassifyUnlockFailure();
|
||||||
|
testSecureWipeAndRemove();
|
||||||
testFormatHttpError();
|
testFormatHttpError();
|
||||||
testRpcFailureKindOf();
|
testRpcFailureKindOf();
|
||||||
testPaymentUri();
|
testPaymentUri();
|
||||||
|
|||||||
Reference in New Issue
Block a user