/* * This file is part of the Flowee project * Copyright (C) 2025 Tom Zander * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "WalletData.h" #include "BackupSyncModuleInfo.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace BCHMDF { enum Tags { BlockHeight = 1, // int: height TransactionIdShort, // 64 bit hash TransactionComment, // utf-8 string LastBlockSeen // int: height. }; } namespace { std::vector derivePrivKey(const std::vector &input) { assert (input.size() == 32); std::vector answer; std::unique_ptr kdf(EVP_KDF_fetch(nullptr, "HKDF", nullptr), ::EVP_KDF_free); if (!kdf.get()) return answer; std::unique_ptr kctx(EVP_KDF_CTX_new(kdf.get()), ::EVP_KDF_CTX_free); if (!kctx.get()) return answer; OSSL_PARAM params[5]; params[0] = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, const_cast("SHA256"), 0); params[1] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_KEY, const_cast(input.data()), input.size()); std::vector salt = { 'f', 'l', 'o', 'w', 'e', 'e', 'p', 'a', 'y' }; uint8_t *info = nullptr; params[2] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, const_cast(salt.data()), salt.size()); params[3] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_INFO, info, 0); params[4] = OSSL_PARAM_construct_end(); if (EVP_KDF_CTX_set_params(kctx.get(), params) <= 0) return answer; answer.resize(32); if (EVP_KDF_derive(kctx.get(), answer.data(), 32, nullptr) <= 0) // Derive the key answer.resize(0); // failed. Return empty. return answer; } // Generate 12-byte nonce from given timestamp std::vector nonceFromTime(uint32_t timestamp) { std::vector nonce(12, 0); constexpr const char *padding = "BCH-MDF"; // Bitcoin Cash MetaData Format std::memcpy(nonce.data(), padding, 7); const uint8_t *timestamp_p = reinterpret_cast(×tamp); // Notice we leave 1 zero byte to allow the timestamp to reach 5 bytes. for (int i = 0; i < 4; ++i) // copy at end of our bytearray nonce[7 + i] = timestamp_p[i]; return nonce; } Streaming::ConstBuffer encrypt(std::vector &key, uint32_t timestamp, const Streaming::ConstBuffer &clearText, const std::shared_ptr &pool) { assert(key.size() == 32); std::unique_ptr ctx( EVP_CIPHER_CTX_new(), ::EVP_CIPHER_CTX_free); if (ctx.get() == nullptr) { logWarning(10050) << "Failed to create EVP_CIPHER_CTX"; return Streaming::ConstBuffer(); } pool->reserve(clearText.size()); // result is the same size as the input const auto nonce = nonceFromTime(timestamp); assert(nonce.size() == 12); if (EVP_EncryptInit_ex(ctx.get(), EVP_chacha20(), nullptr, key.data(), nonce.data()) != 1) { return Streaming::ConstBuffer(); } int len = 0; if (EVP_EncryptUpdate(ctx.get(), reinterpret_cast(pool->begin()), &len, reinterpret_cast(clearText.begin()), clearText.size()) != 1) { logWarning(10050) << "Failed to create update encryption"; return Streaming::ConstBuffer(); } int ciphertext_len = len; if (EVP_EncryptFinal_ex(ctx.get(), reinterpret_cast(pool->begin() + len), &len) != 1) { logWarning(10050) << "Failed to finalize encryption"; return Streaming::ConstBuffer(); } ciphertext_len += len; return pool->commit(ciphertext_len); } Streaming::ConstBuffer decrypt(std::vector &key, uint32_t timestamp, const Streaming::ConstBuffer &cipherText, const std::shared_ptr &pool) { assert(key.size() == 32); std::unique_ptr ctx( EVP_CIPHER_CTX_new(), ::EVP_CIPHER_CTX_free); if (ctx.get() == nullptr) { logWarning(10050) << "Failed to create EVP_CIPHER_CTX"; return Streaming::ConstBuffer(); } const auto nonce = nonceFromTime(timestamp); assert(nonce.size() == 12); if (EVP_DecryptInit_ex(ctx.get(), EVP_chacha20(), nullptr, key.data(), nonce.data()) != 1) { logWarning(10050) << "Failed to initialize decryption"; return Streaming::ConstBuffer(); } int len = 0; if (EVP_DecryptUpdate(ctx.get(), reinterpret_cast(pool->begin()), &len, reinterpret_cast(cipherText.begin()), cipherText.size()) != 1) { logWarning(10050) << "Failed to update decryption"; return Streaming::ConstBuffer(); } int plaintext_len = len; if (EVP_DecryptFinal_ex(ctx.get(), reinterpret_cast(pool->begin() + len), &len) != 1) { logWarning(10050) << "Failed to finalize decryption"; return Streaming::ConstBuffer(); } plaintext_len += len; return pool->commit(plaintext_len); } } WalletData::WalletData(const std::shared_ptr &wallet, BackupSyncModuleInfo *parent) : QObject(parent), m_parent(parent), m_wallet(wallet) { connect (m_wallet.get(), &Wallet::appendedTransactions, this, &WalletData::onTxAdded); connect (m_wallet.get(), &Wallet::transactionConfirmed, this, &WalletData::onTxConfirmed); connect (m_wallet.get(), &Wallet::transactionChanged, this, &WalletData::onTxUpdated); connect (this, SIGNAL(startUpload()), this, SLOT(upload()), Qt::QueuedConnection); } void WalletData::onTxUpdated(int txIndex) { if (m_needsSave) return; auto info = m_wallet->fetchTransactionInfoSmall(txIndex); assert(info.exists()); if (info.isUnconfirmed() || info.isRejected()) return; // ensure we save user comments. // we skip mixTx's since the comment is auto-generated. if (!info.isMixTx && !info.userComment.isEmpty()) markDirty(); } void WalletData::onTxConfirmed(int txIndex) { if (m_needsSave) return; auto info = m_wallet->fetchTransactionInfoSmall(txIndex); assert(info.exists()); if (!info.isMixTx && !info.userComment.isEmpty()) markDirty(); } void WalletData::onTxAdded(int firstNew, int count) { if (m_needsSave) return; for (int i = firstNew; i < firstNew + count; ++i) { auto info = m_wallet->fetchTransactionInfoSmall(i); assert(info.exists()); if (info.isUnconfirmed() || info.isRejected()) continue; if (!info.isMixTx && !info.userComment.isEmpty()) { markDirty(); break; } } } WalletData::SaveData WalletData::save() { /* * What we save is; * a list of block heights where transactions are found relevant for our wallet. * Interleaved the txid if needed, and then the transaction comment. * Future: add metadata for p2sh or p2s style transactions, in order to be able * to detect them on chain and thus later spend them. * If it is already spent, maybe just a list of outputs that are 'mine' ? * Last, a blockheight at which the wallet was saved. Implying that all newer * blocks need checking. * * This is saved to a block which is then put in an envelope as an encrypted blob. * The envelope contains the timestamp, which is needed to decrypt the data. * The envelope is then also signed with the original identity resolved from the * wallets HD master key. */ assert(m_wallet); assert(m_wallet->isHDWallet()); SaveData saveData; if (!deriveAuthKey()) return saveData; assert(m_privateKey.isValid()); m_needsSave = false; int txIndex = 1; int blockHeight = 0; int bytes = 0; do { auto info = m_wallet->fetchTransactionInfoSmall(txIndex++); if (!info.exists()) break; if (info.minedBlockHeight < 0) continue; if (info.minedBlockHeight != blockHeight) { bytes += 10; blockHeight = info.minedBlockHeight; } if (!info.userComment.isEmpty()) bytes += 11 + info.userComment.size() * 3; } while (true); // TODO have a max size? // now actually do it. auto pool = Streaming::pool(bytes + 20000); Streaming::MessageBuilder builder(pool); txIndex = 1; do { // iterate over all transactions auto info = m_wallet->fetchTransactionInfoSmall(txIndex++); if (!info.exists()) break; if (info.minedBlockHeight < 0) continue; if (info.minedBlockHeight != blockHeight) { blockHeight = info.minedBlockHeight; builder.add(BCHMDF::BlockHeight, blockHeight); } if (!info.userComment.isEmpty()) { builder.addByteArray(BCHMDF::TransactionIdShort, info.txid.begin(), 3); builder.add(BCHMDF::TransactionComment, info.userComment.toUtf8().constData()); } } while (true); int lastSavedHeight = m_wallet->segment()->lastBlockSynched(); builder.add(BCHMDF::LastBlockSeen, lastSavedHeight); auto theData = pool->commit(); logDebug(10050) << "Data size:" << theData.size(); if (theData.isEmpty()) return saveData; CSHA256 messageHasher; messageHasher.write(theData.begin(), theData.size()); char buf[CSHA256::OUTPUT_SIZE]; messageHasher.finalize(buf); saveData.dataHash = uint256(buf); if (saveData.dataHash == m_lastSaveHash) return saveData; // we encrypt the data so only the owner can understand it. // step one, use the bitcoin private key (secp256k1) and derive a // private key from that to use for our symmetric crypto algo std::vector derivedPrivKey = derivePrivKey(std::vector( m_privateKey.begin(), m_privateKey.end())); if (derivedPrivKey.size() != 32) { logCritical(10050) << "Failed to derive priv key via HKDF"; return saveData; } assert(derivedPrivKey.size() == 32); saveData.timestamp = QDateTime::currentSecsSinceEpoch(); theData = encrypt(derivedPrivKey, saveData.timestamp, theData, pool); if (theData.isEmpty()) { logDebug(10050) << "encrypt failed"; return saveData; } pool->reserve(100 + theData.size()); pool->writeInt32(saveData.timestamp); pool->writeInt32(theData.size()); pool->write(theData); // we need to create a base image to sign, the above data is what we'll use. CSHA256 hasher; hasher.write(pool->begin(), pool->size()); hasher.finalize(buf); std::vector signature; m_privateKey.signCompact(uint256(buf), signature); assert(signature.size() == 65); pool->write(signature.data(), signature.size()); saveData.data = pool->commit(); #if 0 QFile backup(m_wallet->name() + "_backup"); if (backup.open(QIODevice::WriteOnly)) backup.write(saveData.data.begin(), saveData.data.size()); else logCritical(10050) << "Failed to open file" << backup.fileName(); #endif return saveData; } void WalletData::upload() { if (m_httpClient.get()) return; if (FloweePay::instance()->isOffline()) return; AccountConfig accountConfig(m_wallet); // honor user config if (!accountConfig.cloudStoreDetails()) return; auto saveData = save(); if (saveData.data.isEmpty()) return; // Prepare the HTTP request boost::beast::http::request> request; request.version(11); request.method(boost::beast::http::verb::post); request.target("/md"); request.set(boost::beast::http::field::host, "flowee.org"); request.set(boost::beast::http::field::content_type, "application/octet-stream"); request.body() = std::vector(saveData.data.begin(), saveData.data.end()); auto *fp = FloweePay::instance(); m_httpClient = SimpleHttpClient::create(fp->ioContext(), fp->sslContext(), request); connect (m_httpClient.get(), &SimpleHttpClient::finished, this, [=]() { if (m_httpClient->error() == SimpleHttpClient::NoError) { m_lastSaveHash = saveData.dataHash; setLastSavedTime(saveData.timestamp); // remember 'when' we saved. emit m_parent->doRequestSave(); } m_httpClient.reset(); }); } void WalletData::restore(RestoreReason reason) { auto start = m_wallet->walletCreatedHeight(); if (start < 0) { // negative numbers mean it is unused. if (reason == BeforeSave) emit startUpload(); return; } auto now = FloweePay::instance()->headerChainHeight(); if (now - start < 2 && m_wallet->lastTransactionTimestamp() == 0) { // wallet is not an imported seed, and is empty. Ignore if (reason == BeforeSave) emit startUpload(); return; } if (!deriveAuthKey()) { // basically only fails on an encrypted wallet return; } // if wallet is not at 'tip', return later const int headerChainHeight = FloweePay::instance()->headerChainHeight(); if (m_wallet->segment()->lastBlockSynched() < headerChainHeight) { emit storeWaitingForSync(); logCritical(10050) << "Delaying restore/upload wallet: not yet at tip."; QTimer::singleShot(10 * 1000, this, [=]() { restore(reason); } ); return; } auto authAddress = fancyAddess(); // check online boost::beast::http::request> request; request.version(11); request.method(boost::beast::http::verb::get); request.target(QString("/md/%1").arg(fancyAddess()).toStdString()); request.set(boost::beast::http::field::host, "flowee.org"); auto *fp = FloweePay::instance(); m_httpClient = SimpleHttpClient::create(fp->ioContext(), fp->sslContext(), request); connect (m_httpClient.get(), &SimpleHttpClient::finished, this, [=]() { auto copySharedPtr = m_httpClient; m_httpClient.reset(); if (copySharedPtr->error() == SimpleHttpClient::NoError) { auto buf = copySharedPtr->responseBody(); const auto doc = QJsonDocument::fromJson(QByteArray(buf.begin(), buf.size())); const auto root = doc.object(); const auto list = root.keys(); uint64_t latest = 0; QString latestStr; for (const auto &item : list) { auto timestampStr = root[item].toString(); bool ok; uint64_t timestamp = timestampStr.toLong(&ok); if (ok && timestamp > latest) { latest = timestamp; latestStr = timestampStr; } } // check to avoid downloading, if latest one was generated by us. if (latest != m_lastSavedTime) restoreFrom(latestStr); } // Notice that if the server has never heard of this wallet before, it returns // http for (or SimpleHttpClient::PeerIssues). As such errors are expected and Ok. // We schedule an upload if that was what the user wanted now. if (reason == BeforeSave) emit startUpload(); }); } void WalletData::markDirty() { if (m_needsSave) return; m_needsSave = true; // we'll do an upload after restore finished. // see connect in constructor. QTimer::singleShot(120 * 1000, this, [=]() { restore(BeforeSave); }); emit m_parent->doRequestSave(); } bool WalletData::deriveAuthKey() { if (!m_privateKey.isValid()) { if (!m_wallet->isDecrypted()) { logInfo(10050) << "Skipping encrypted wallet" << m_wallet->name(); return false; } HDMasterKey mk = HDMasterKey::fromMnemonic(m_wallet->hdWalletMnemonic().toStdString(), HDMasterKey::BIP39Mnemonic); auto pathVector = HDMasterKey::deriveFromString(m_wallet->derivationPath().toStdString()); assert(pathVector.size() == 3); pathVector.resize(5); pathVector[3] = 3; // the Ownership chain. pathVector[4] = 0; // First key is reserved for backups. m_privateKey = mk.derive(pathVector); } return true; } void WalletData::restoreFrom(const QString ×tamp) { if (m_httpClient.get()) return; boost::beast::http::request> request; request.version(11); request.method(boost::beast::http::verb::get); request.target(QString("/md/%1/%2").arg(fancyAddess(), timestamp).toStdString()); request.set(boost::beast::http::field::host, "flowee.org"); auto *fp = FloweePay::instance(); m_httpClient = SimpleHttpClient::create(fp->ioContext(), fp->sslContext(), request); connect (m_httpClient.get(), &SimpleHttpClient::finished, this, [=]() { if (m_httpClient->error() == SimpleHttpClient::NoError) restoreDownload(m_httpClient->responseBody()); m_httpClient.reset(); }); } void WalletData::restoreDownload(const Streaming::ConstBuffer &blob) { if (blob.size() < 77) { logCritical(10050) << "The downloaded backup is too small." << blob.size(); return; } auto timestampData = blob.mid(0, 4); uint32_t timestamp = *reinterpret_cast(timestampData.begin()); auto dataSizeData = blob.mid(4, 4); uint32_t dataSize = *reinterpret_cast(dataSizeData.begin()); if (blob.size() - 65 - 4 - 4 != (int) dataSize) { logCritical(10050) << "Data-size invalid"; return; } auto data = blob.mid(8, dataSize); auto signature = blob.mid(8 + dataSize, 65); PublicKey pk; CSHA256 hasher; hasher.write(blob.begin(), 8 + dataSize); char buf[CSHA256::OUTPUT_SIZE]; hasher.finalize(buf); uint256 hash(buf); std::vector sig(signature.begin(), signature.end()); if (!pk.recoverCompact(hash, sig)) { logWarning(10050) << "Download invalid"; return; } deriveAuthKey(); if (pk != m_privateKey.getPubKey()) { logWarning(10050) << "Download not created by us"; return; } if (!pk.verifyCompact(hash, sig)) { logWarning(10050) << "Downloads' signature invalid"; return; } std::vector derivedPrivKey = derivePrivKey(std::vector( m_privateKey.begin(), m_privateKey.end())); auto message = decrypt(derivedPrivKey, timestamp, data, Streaming::pool(0)); Streaming::MessageParser parser(message); // Today we simply apply comments, nothing more. Streaming::ConstBuffer txIdShort; int txIndex = 1; int txIndexInBlock = 1; int height = -1; while (parser.next() == Streaming::FoundTag) { if (parser.tag() == BCHMDF::BlockHeight) { txIdShort.clear(); height = parser.intData(); txIndexInBlock = txIndex; } else if (parser.tag() == BCHMDF::TransactionIdShort) { txIdShort = parser.bytesDataBuffer(); } else if (parser.tag() == BCHMDF::TransactionComment) { txIndex = txIndexInBlock; // check all transactions in block for match. while (txIdShort.size() > 1 && txIdShort.size() <= 32) { // 32 is sizeof uint256 auto info = m_wallet->fetchTransactionInfoSmall(txIndex); if (!info.exists()) break; assert(txIdShort.size() <= (int) info.txid.size()); if (info.minedBlockHeight > height) break; if (info.minedBlockHeight < height) txIndexInBlock = txIndex; if (info.minedBlockHeight == height && memcmp(info.txid.begin(), txIdShort.begin(), txIdShort.size()) == 0) { // match! if (info.userComment.isEmpty()) { // don't overwrite auto bytes = parser.bytesDataBuffer(); m_wallet->setTransactionComment(txIndex, QString::fromUtf8(bytes.begin(), bytes.size())); } ++txIndex; break; } ++txIndex; } } } } uint32_t WalletData::lastSavedTime() const { return m_lastSavedTime; } void WalletData::setLastSavedTime(uint32_t newLastSavedTime) { m_lastSavedTime = newLastSavedTime; emit lastSavedChanged(); } bool WalletData::needsSave() const { return m_needsSave; } std::shared_ptr WalletData::wallet() const { return m_wallet; } void WalletData::setPrivKeyData(const Streaming::ConstBuffer &data) { m_privateKey.set( reinterpret_cast(data.begin()), reinterpret_cast(data.end())); } const PrivateKey &WalletData::privKey() const { return m_privateKey; } QString WalletData::fancyAddess() const { if (!m_privateKey.isValid()) return QString(); auto id = m_privateKey.getPubKey().getKeyId(); return renderAddress(id); }