Files
nerbus-wallet/wallet_store.cpp
T

281 lines
13 KiB
C++

#include "wallet_store.h"
#include <sodium.h>
#include <stdexcept>
namespace {
constexpr int KeyBytes = crypto_aead_xchacha20poly1305_ietf_KEYBYTES;
constexpr int MaxWalletBytes = 8 * 1024 * 1024;
const QByteArray Aad = "Nerbus encrypted wallet v1|NRB|" + NrbGenesis.toLatin1();
constexpr unsigned long long KdfOps = 3;
constexpr size_t KdfMemory = 64 * 1024 * 1024;
QByteArray readLimited(const QString &path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly) || file.size() < 1 || file.size() > MaxWalletBytes)
walletError("Cannot read wallet file.");
return file.readAll();
}
QJsonObject walletHeader(const QByteArray &file) {
QJsonParseError parse;
const auto doc = QJsonDocument::fromJson(file, &parse);
const auto header = doc.object();
if (parse.error != QJsonParseError::NoError || !doc.isObject() ||
header["format"] != "NerbusWallet" || header["genesis"].toString() != NrbGenesis ||
(header["version"].toInt() != 1 &&
!(header["version"].toInt() == 2 && header["protection"] == "none")))
walletError("Unsupported wallet file.");
return header;
}
QJsonObject walletContents(const QJsonObject &data) {
if (data["genesis"].toString() != NrbGenesis || !data["keys"].isArray() ||
data["keys"].toArray().size() > 1000)
walletError("Invalid wallet contents.");
return data;
}
void atomicWrite(const QString &path, const QByteArray &data) {
QSaveFile file(path);
file.setDirectWriteFallback(false);
if (!file.open(QIODevice::WriteOnly) ||
!file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner) ||
file.write(data) != data.size() || !file.commit())
walletError("Cannot save wallet file. Check the folder permissions and free space.");
}
unsigned char *newSecret() {
auto *p = static_cast<unsigned char *>(sodium_malloc(KeyBytes));
if (!p) walletError("Cannot allocate secure wallet memory.");
return p;
}
void derive(QByteArray &password, const QByteArray &salt, unsigned char *key) {
const int result = crypto_pwhash(key, KeyBytes, password.constData(), password.size(),
reinterpret_cast<const unsigned char *>(salt.constData()), KdfOps, KdfMemory, crypto_pwhash_ALG_ARGON2ID13);
wipe(password);
if (result != 0) walletError("Not enough memory to unlock the wallet.");
}
}
void wipe(QByteArray &bytes) {
if (!bytes.isEmpty()) sodium_memzero(bytes.data(), bytes.size());
bytes.clear();
}
void walletError(const QString &message) { throw std::runtime_error(message.toUtf8().constData()); }
QJsonObject offlineCore(QJsonObject request) {
QProcess process;
QString executable = QCoreApplication::applicationDirPath() + "/nerbus-wallet-core";
#ifdef Q_OS_WIN
executable += ".exe";
#endif
if (!QFileInfo::exists(executable)) walletError("The wallet signing component is missing. Extract the complete wallet package.");
process.setProgram(executable);
process.setArguments({"-wallet-json"});
process.setProcessChannelMode(QProcess::SeparateChannels);
process.start();
if (!process.waitForStarted(10000)) walletError("Cannot start the wallet signing component.");
QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact);
request = {};
const qint64 expected = payload.size();
const qint64 written = process.write(payload);
wipe(payload);
process.closeWriteChannel();
if (written != expected || !process.waitForFinished(60000)) {
process.kill(); process.waitForFinished(3000);
walletError("The wallet signing component did not finish.");
}
QByteArray output = process.readAllStandardOutput();
QByteArray errors = process.readAllStandardError();
wipe(errors); // Never display process diagnostics that could contain private input.
QJsonParseError parse;
QJsonDocument doc = QJsonDocument::fromJson(output, &parse);
wipe(output);
if (parse.error != QJsonParseError::NoError || !doc.isObject())
walletError("The wallet signing component returned an invalid response.");
QJsonObject result = doc.object();
if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0 || result.contains("error"))
walletError(result.value("error").toString("Wallet operation failed."));
return result;
}
WalletStore::WalletStore(QString path) : filename(std::move(path)) {}
WalletStore::~WalletStore() { lock(); }
bool WalletStore::exists() const { return QFileInfo::exists(filename); }
bool WalletStore::fileRequiresPassword(const QString &path) {
QByteArray file = readLimited(path);
try {
const bool protectedFile = walletHeader(file)["version"].toInt() == 1;
wipe(file);
return protectedFile;
} catch (...) { wipe(file); throw; }
}
QJsonObject WalletStore::decrypt(const QByteArray &file, QByteArray &password,
unsigned char *outKey, QByteArray &outSalt) {
QJsonObject header = walletHeader(file);
if (header["version"].toInt() == 2) {
wipe(password);
outSalt.clear();
return walletContents(header["data"].toObject());
}
if (!outKey || header["kdf"] != "argon2id13" ||
header["ops"].toInt() != int(KdfOps) || header["memory"].toInt() != int(KdfMemory))
walletError("Unsupported wallet file.");
outSalt = QByteArray::fromBase64(header["salt"].toString().toLatin1());
QByteArray nonce = QByteArray::fromBase64(header["nonce"].toString().toLatin1());
QByteArray cipher = QByteArray::fromBase64(header["ciphertext"].toString().toLatin1());
if (outSalt.size() != crypto_pwhash_SALTBYTES || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES)
walletError("Incomplete wallet file.");
derive(password, outSalt, outKey);
QByteArray clear(cipher.size(), Qt::Uninitialized);
unsigned long long length = 0;
int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
reinterpret_cast<unsigned char *>(clear.data()), &length, nullptr,
reinterpret_cast<const unsigned char *>(cipher.constData()), cipher.size(),
reinterpret_cast<const unsigned char *>(Aad.constData()), Aad.size(),
reinterpret_cast<const unsigned char *>(nonce.constData()), outKey);
if (rc != 0) { wipe(clear); walletError("Incorrect password or damaged wallet file."); }
clear.resize(qsizetype(length));
QJsonParseError parse;
QJsonDocument payload = QJsonDocument::fromJson(clear, &parse);
wipe(clear);
auto data = payload.object();
if (parse.error != QJsonParseError::NoError)
walletError("Invalid wallet contents.");
return walletContents(data);
}
void WalletStore::save(const QJsonObject &data) {
if (!unlocked()) walletError("Open the wallet first.");
QByteArray clear = QJsonDocument(data).toJson(QJsonDocument::Compact);
if (plainOpen) {
const QJsonObject header{{"format", "NerbusWallet"}, {"version", 2},
{"genesis", NrbGenesis}, {"protection", "none"}, {"data", data}};
QByteArray file = QJsonDocument(header).toJson(QJsonDocument::Compact);
try { atomicWrite(filename, file); }
catch (...) { wipe(file); wipe(clear); throw; }
wipe(file);
wipe(plaintext);
plaintext = std::move(clear);
return;
}
QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, Qt::Uninitialized);
randombytes_buf(nonce.data(), nonce.size());
QByteArray cipher(clear.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
unsigned long long length = 0;
crypto_aead_xchacha20poly1305_ietf_encrypt(reinterpret_cast<unsigned char *>(cipher.data()), &length,
reinterpret_cast<const unsigned char *>(clear.constData()), clear.size(),
reinterpret_cast<const unsigned char *>(Aad.constData()), Aad.size(), nullptr,
reinterpret_cast<const unsigned char *>(nonce.constData()), key);
QJsonObject header{{"format", "NerbusWallet"}, {"version", 1}, {"genesis", NrbGenesis},
{"kdf", "argon2id13"}, {"ops", int(KdfOps)}, {"memory", int(KdfMemory)},
{"salt", QString::fromLatin1(salt.toBase64())}, {"nonce", QString::fromLatin1(nonce.toBase64())},
{"ciphertext", QString::fromLatin1(cipher.toBase64())}};
try { atomicWrite(filename, QJsonDocument(header).toJson(QJsonDocument::Compact)); }
catch (...) { wipe(clear); throw; }
wipe(plaintext);
plaintext = std::move(clear);
}
void WalletStore::create(QByteArray password) {
if (exists()) { wipe(password); walletError("A wallet already exists here."); }
if (!QDir().mkpath(QFileInfo(filename).absolutePath())) walletError("Cannot create the wallet folder.");
lock();
try {
plainOpen = password.isEmpty();
if (!plainOpen) {
key = newSecret();
salt.resize(crypto_pwhash_SALTBYTES);
randombytes_buf(salt.data(), salt.size());
derive(password, salt, key);
}
save({{"genesis", NrbGenesis}, {"keys", QJsonArray{}},
{"created", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
} catch (...) { wipe(password); lock(); throw; }
}
void WalletStore::unlock(QByteArray password) {
lock();
QByteArray file;
try {
file = readLimited(filename);
plainOpen = walletHeader(file)["version"].toInt() == 2;
if (!plainOpen) key = newSecret();
QJsonObject data = decrypt(file, password, key, salt);
plaintext = QJsonDocument(data).toJson(QJsonDocument::Compact);
wipe(file);
} catch (...) { wipe(file); wipe(password); lock(); throw; }
}
void WalletStore::changePassword(QByteArray password) {
if (!unlocked()) { wipe(password); walletError("Open the wallet first."); }
unsigned char *candidate = nullptr;
QByteArray candidateSalt;
try {
if (!password.isEmpty()) {
candidate = newSecret();
candidateSalt.resize(crypto_pwhash_SALTBYTES);
randombytes_buf(candidateSalt.data(), candidateSalt.size());
derive(password, candidateSalt, candidate);
}
} catch (...) { wipe(password); if (candidate) sodium_free(candidate); throw; }
auto *oldKey = key;
const QByteArray oldSalt = salt;
const bool wasPlain = plainOpen;
key = candidate; salt = candidateSalt; plainOpen = candidate == nullptr;
try { save(QJsonDocument::fromJson(plaintext).object()); }
catch (...) {
if (candidate) sodium_free(candidate);
key = oldKey; salt = oldSalt; plainOpen = wasPlain;
throw;
}
if (oldKey) sodium_free(oldKey);
}
void WalletStore::lock() {
wipe(plaintext);
if (key) { sodium_free(key); key = nullptr; }
plainOpen = false;
salt.clear();
}
QJsonArray WalletStore::entries() const {
if (!unlocked()) walletError("Open the wallet first.");
return QJsonDocument::fromJson(plaintext).object()["keys"].toArray();
}
QString WalletStore::addKey(const QJsonObject &pair, const QString &label) {
if (!unlocked()) walletError("Open the wallet first.");
QString address = pair["address"].toString();
if (address.isEmpty() || pair["privateKey"].toString().isEmpty()) walletError("Invalid key response.");
QJsonObject data = QJsonDocument::fromJson(plaintext).object();
QJsonArray keys = data["keys"].toArray();
for (const auto &entry : keys) if (entry.toObject()["address"] == address) return address;
if (keys.size() >= 1000) walletError("This wallet already contains 1,000 addresses.");
keys.append(QJsonObject{{"address", address}, {"privateKey", pair["privateKey"]},
{"label", label.left(200)}, {"created", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
data["keys"] = keys;
save(data); // Persist the private key before returning an address to the UI.
return address;
}
void WalletStore::backup(const QString &destination) const {
if (QFileInfo(destination).absoluteFilePath() == QFileInfo(filename).absoluteFilePath())
walletError("Choose a different location for the backup.");
QByteArray file = readLimited(filename);
try { atomicWrite(destination, file); }
catch (...) { wipe(file); throw; }
wipe(file);
}
void WalletStore::restore(const QString &source, QByteArray password) {
if (exists()) { wipe(password); walletError("Restore into a new wallet folder to preserve the current wallet."); }
QByteArray file = readLimited(source);
auto *candidate = newSecret();
QByteArray candidateSalt;
try {
const auto data = decrypt(file, password, candidate, candidateSalt);
if (!QDir().mkpath(QFileInfo(filename).absolutePath())) walletError("Cannot create the wallet folder.");
atomicWrite(filename, file);
lock();
plainOpen = walletHeader(file)["version"].toInt() == 2;
if (plainOpen) sodium_free(candidate);
else { key = candidate; salt = candidateSalt; }
candidate = nullptr;
plaintext = QJsonDocument(data).toJson(QJsonDocument::Compact);
} catch (...) { wipe(file); wipe(password); if (candidate) sodium_free(candidate); throw; }
wipe(file);
wipe(password);
}