removeVault() overwrote vault.dat with zeros then unlinked it, but never flushed to stable storage, so the zeros could stay in the OS cache and never reach disk. flush + fsync before unlink on POSIX (still best-effort on CoW/SSD, but now does what it claims). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
7.8 KiB
C++
259 lines
7.8 KiB
C++
// ObsidianDragon Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "secure_vault.h"
|
|
#include "platform.h"
|
|
|
|
#include <sodium.h>
|
|
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <cstring>
|
|
#include <algorithm>
|
|
#include "../util/logger.h"
|
|
|
|
#ifndef _WIN32
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace dragonx {
|
|
namespace util {
|
|
|
|
// Vault file format version
|
|
static constexpr uint8_t VAULT_VERSION = 0x01;
|
|
|
|
// Argon2id parameters — moderate: ~256 MB, ~3 ops
|
|
// Tuned for short PINs to make brute-force costly
|
|
static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE;
|
|
static constexpr unsigned long long ARGON2_OPSLIMIT = crypto_pwhash_OPSLIMIT_MODERATE;
|
|
|
|
SecureVault::SecureVault() {
|
|
// Ensure libsodium is initialized
|
|
if (sodium_init() < 0) {
|
|
// sodium_init returns 0 on success, 1 if already initialized, -1 on failure
|
|
// We'll proceed anyway — the functions will fail gracefully
|
|
}
|
|
}
|
|
|
|
SecureVault::~SecureVault() = default;
|
|
|
|
std::string SecureVault::getVaultPath() {
|
|
return (fs::path(Platform::getConfigDir()) / "vault.dat").string();
|
|
}
|
|
|
|
bool SecureVault::hasVault() const {
|
|
std::error_code ec;
|
|
return fs::exists(getVaultPath(), ec);
|
|
}
|
|
|
|
bool SecureVault::isValidPin(const std::string& pin) {
|
|
if (pin.size() < 4 || pin.size() > 8) return false;
|
|
return std::all_of(pin.begin(), pin.end(), ::isdigit);
|
|
}
|
|
|
|
bool SecureVault::deriveKey(const std::string& pin,
|
|
const uint8_t* salt, size_t saltLen,
|
|
uint8_t* key, size_t keyLen) const {
|
|
if (saltLen < crypto_pwhash_SALTBYTES) return false;
|
|
|
|
int rc = crypto_pwhash(
|
|
key, keyLen,
|
|
pin.c_str(), pin.size(),
|
|
salt,
|
|
ARGON2_OPSLIMIT,
|
|
ARGON2_MEMLIMIT,
|
|
crypto_pwhash_ALG_ARGON2ID13
|
|
);
|
|
return rc == 0;
|
|
}
|
|
|
|
void SecureVault::secureZero(void* ptr, size_t len) {
|
|
sodium_memzero(ptr, len);
|
|
}
|
|
|
|
bool SecureVault::store(const std::string& pin, const std::string& passphrase) {
|
|
if (!isValidPin(pin)) {
|
|
DEBUG_LOGF("[SecureVault] Invalid PIN format\n");
|
|
return false;
|
|
}
|
|
if (passphrase.empty()) {
|
|
DEBUG_LOGF("[SecureVault] Empty passphrase\n");
|
|
return false;
|
|
}
|
|
|
|
// Generate random salt and nonce
|
|
uint8_t salt[crypto_pwhash_SALTBYTES];
|
|
uint8_t nonce[crypto_secretbox_NONCEBYTES];
|
|
randombytes_buf(salt, sizeof(salt));
|
|
randombytes_buf(nonce, sizeof(nonce));
|
|
|
|
// Derive key from PIN
|
|
uint8_t key[crypto_secretbox_KEYBYTES];
|
|
if (!deriveKey(pin, salt, sizeof(salt), key, sizeof(key))) {
|
|
DEBUG_LOGF("[SecureVault] Key derivation failed\n");
|
|
return false;
|
|
}
|
|
|
|
// Encrypt passphrase
|
|
size_t plainLen = passphrase.size();
|
|
size_t cipherLen = plainLen + crypto_secretbox_MACBYTES;
|
|
std::vector<uint8_t> ciphertext(cipherLen);
|
|
|
|
int rc = crypto_secretbox_easy(
|
|
ciphertext.data(),
|
|
reinterpret_cast<const uint8_t*>(passphrase.c_str()),
|
|
plainLen,
|
|
nonce,
|
|
key
|
|
);
|
|
|
|
// Wipe key immediately
|
|
secureZero(key, sizeof(key));
|
|
|
|
if (rc != 0) {
|
|
DEBUG_LOGF("[SecureVault] Encryption failed\n");
|
|
return false;
|
|
}
|
|
|
|
// Write vault file: version + salt + nonce + ciphertext.
|
|
// Atomic + owner-only (0600): the vault holds the PIN-encrypted passphrase, so it must
|
|
// not be world-readable (a copy enables an offline brute-force of the short PIN), and a
|
|
// crash mid-write must not leave a truncated vault.
|
|
std::string vaultPath = getVaultPath();
|
|
std::string content;
|
|
content.reserve(1 + sizeof(salt) + sizeof(nonce) + cipherLen);
|
|
content.push_back(static_cast<char>(VAULT_VERSION));
|
|
content.append(reinterpret_cast<const char*>(salt), sizeof(salt));
|
|
content.append(reinterpret_cast<const char*>(nonce), sizeof(nonce));
|
|
content.append(reinterpret_cast<const char*>(ciphertext.data()), cipherLen);
|
|
|
|
if (!Platform::writeFileAtomically(vaultPath, content, /*restrictPermissions=*/true)) {
|
|
DEBUG_LOGF("[SecureVault] Write failed\n");
|
|
return false;
|
|
}
|
|
|
|
DEBUG_LOGF("[SecureVault] Vault saved (%zu bytes ciphertext)\n", cipherLen);
|
|
return true;
|
|
}
|
|
|
|
bool SecureVault::retrieve(const std::string& pin, std::string& passphrase) const {
|
|
if (!isValidPin(pin)) return false;
|
|
|
|
std::string vaultPath = getVaultPath();
|
|
std::ifstream in(vaultPath, std::ios::binary);
|
|
if (!in.is_open()) return false;
|
|
|
|
// Read entire file
|
|
in.seekg(0, std::ios::end);
|
|
size_t fileSize = in.tellg();
|
|
in.seekg(0);
|
|
|
|
// Minimum size: 1 (version) + SALTBYTES + NONCEBYTES + MACBYTES + 1 (min plaintext)
|
|
size_t minSize = 1 + crypto_pwhash_SALTBYTES + crypto_secretbox_NONCEBYTES
|
|
+ crypto_secretbox_MACBYTES + 1;
|
|
if (fileSize < minSize) {
|
|
DEBUG_LOGF("[SecureVault] Vault file too small\n");
|
|
return false;
|
|
}
|
|
|
|
std::vector<uint8_t> data(fileSize);
|
|
in.read(reinterpret_cast<char*>(data.data()), fileSize);
|
|
in.close();
|
|
|
|
// Parse header
|
|
size_t offset = 0;
|
|
uint8_t version = data[offset++];
|
|
if (version != VAULT_VERSION) {
|
|
DEBUG_LOGF("[SecureVault] Unknown vault version: %d\n", version);
|
|
return false;
|
|
}
|
|
|
|
const uint8_t* salt = &data[offset];
|
|
offset += crypto_pwhash_SALTBYTES;
|
|
|
|
const uint8_t* nonce = &data[offset];
|
|
offset += crypto_secretbox_NONCEBYTES;
|
|
|
|
const uint8_t* ciphertext = &data[offset];
|
|
size_t cipherLen = fileSize - offset;
|
|
size_t plainLen = cipherLen - crypto_secretbox_MACBYTES;
|
|
|
|
// Derive key
|
|
uint8_t key[crypto_secretbox_KEYBYTES];
|
|
if (!deriveKey(pin, salt, crypto_pwhash_SALTBYTES, key, sizeof(key))) {
|
|
DEBUG_LOGF("[SecureVault] Key derivation failed during retrieve\n");
|
|
return false;
|
|
}
|
|
|
|
// Decrypt
|
|
std::vector<uint8_t> plaintext(plainLen);
|
|
int rc = crypto_secretbox_open_easy(
|
|
plaintext.data(),
|
|
ciphertext,
|
|
cipherLen,
|
|
nonce,
|
|
key
|
|
);
|
|
|
|
secureZero(key, sizeof(key));
|
|
|
|
if (rc != 0) {
|
|
// Wrong PIN or corrupted data
|
|
return false;
|
|
}
|
|
|
|
passphrase.assign(reinterpret_cast<const char*>(plaintext.data()), plainLen);
|
|
secureZero(plaintext.data(), plainLen);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool SecureVault::changePin(const std::string& oldPin, const std::string& newPin) {
|
|
std::string passphrase;
|
|
if (!retrieve(oldPin, passphrase)) {
|
|
return false;
|
|
}
|
|
|
|
bool ok = store(newPin, passphrase);
|
|
secureZero(&passphrase[0], passphrase.size());
|
|
return ok;
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
} // namespace util
|
|
} // namespace dragonx
|