Publish Nerbus source snapshot and build documentation
This commit is contained in:
commit
73401987df
17 files changed
+2169
No files matched your search
@@ -0,0 +1,280 @@
|
||||
#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);
|
||||
}
|
||||
Reference in new issue
Block a user