catcrafts.net/server/implementations/Catcrafts.Server-Bunq.cpp

629 lines
27 KiB
C++
Raw Normal View History

2026-08-20 20:15:47 +02:00
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The bunq credit source: reads the shop's OWN bank account and reports the
// incoming credits, which is all the transfer rail needs to settle an order.
//
// This is a READER. It never moves money, and that asymmetry is the only
// reason it can exist at all — see the key-policy note below, which is the
// most important thing in this file.
//
// Descended from the bunq PAYMENT rail this repo carried until 2026-08-14
// (deleted in 70668af): the four-call onboarding dance, the RSA body signing
// and the context file are that code, kept because they were already proven
// against the live API. What changed is the question asked at the end. The old
// rail created bunq.me tabs and polled them; that route is closed to a webshop
// on volume grounds (a €1,500/week ceiling across all of bunq.me, and no way
// to disable its 2.5% card path), so this one reads the account's payment list
// instead and lets the reference do the matching.
//
// installation (once, ever) -> installation token
// device-server (once, ever) -> binds the API key to this "device"
// session-server (per session) -> session token + user id
// monetary-account (once) -> which account to read
// payment (per poll) -> the credits
//
// State persists in ONE json file so the first four never repeat: bunq allows
// as few as TEN calls PER DAY to the setup endpoints, so a client that
// re-onboarded on every start would lock itself out by lunchtime. Delete the
// file and it re-onboards from the API key.
//
// ── THE KEY POLICY, which this file cannot enforce on its own ─────────
//
// A bunq API key can INITIATE PAYMENTS. bunq offers no read-only scope, so
// there is no such thing as a key that can only do what this file does. The
// standing rule for this project is therefore that the bunq key does NOT live
// on the internet-facing box.
//
// That rule and this code are compatible, but only in one deployment shape:
// run this as `catcrafts-server --pull-credits` on a trusted machine, on a
// timer, and ship the resulting credits file to the server, which reads it
// with the file source and holds no key at all. The server then cannot be made
// to move money even if it is fully compromised.
//
// Configuring BUNQ_API_KEY on the server itself also works and is one less
// moving part, but it puts a payment-capable credential on a public host. The
// startup path says so out loud rather than letting it pass unnoticed.
module;
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
namespace Catcrafts::Server {
namespace {
std::string Base64B(std::span<const unsigned char> in) {
static constexpr char tbl[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((in.size() + 2) / 3) * 4);
std::size_t i = 0;
for (; i + 2 < in.size(); i += 3) {
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
}
if (i + 1 == in.size()) {
const std::uint32_t n = in[i] << 16;
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += "==";
} else if (i + 2 == in.size()) {
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
out += tbl[(n >> 6) & 63]; out += '=';
}
return out;
}
std::string EscB(std::string_view s) {
std::string out;
out.reserve(s.size() + 8);
for (const char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
} else {
out += c;
}
}
}
return out;
}
std::string RandomHexB(std::size_t words) {
std::random_device rd;
std::string out;
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
return out;
}
// bunq wraps everything: {"Response":[{"Id":{…}},{"Token":{…}}]}. Find the
// first object under `key` anywhere in that array.
const Json::Value* InResponse(const Json::Value& doc, std::string_view key) {
const Json::Value* resp = doc.Find("Response");
if (!resp || !resp->IsArray()) return nullptr;
for (const Json::Value& item : resp->array) {
if (!item.IsObject()) continue;
if (const Json::Value* v = item.Find(key)) return v;
}
return nullptr;
}
} // namespace
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s) {
// bunq quotes an OUTGOING payment as a negative value ("-25.00"), and the
// shared money parser rejects a sign by design — it guards amounts we
// choose, where a minus would be nonsense. Here the sign is information:
// it is what separates a customer paying us from us paying a supplier, and
// dropping it would let a refund look like income. So the sign is peeled
// off here and the magnitude goes through the strict parser unchanged.
bool negative = false;
if (!s.empty() && (s.front() == '-' || s.front() == '+')) {
negative = s.front() == '-';
s.remove_prefix(1);
}
const std::optional<std::int64_t> magnitude = ParseAmountToMinor(s);
if (!magnitude) return std::nullopt;
return negative ? -*magnitude : *magnitude;
}
std::string_view BunqMethodFor(std::string_view paymentType) {
// bunq's Payment.type, mapped to the ledger's `via` vocabulary. The
// distinction earns its keep at dispatch time: a SEPA credit transfer is
// final, while a card payment can be reversed for months, so these are not
// interchangeable labels for "money arrived".
if (paymentType == "EBA_SCT") return "sepa";
if (paymentType == "IDEAL") return "ideal";
if (paymentType == "FIS") return "card";
if (paymentType == "BUNQ") return "bunq";
if (paymentType == "SWIFT") return "swift";
if (paymentType == "EBA_SDD") return "directdebit";
// An unknown type still settles — the money is on the account either way —
// but it reaches the ledger verbatim so the `via` column shows what bunq
// actually said instead of a comfortable guess.
return paymentType.empty() ? std::string_view("bank") : paymentType;
}
std::vector<BankCredit> ParseBunqPayments(std::string_view json) {
std::vector<BankCredit> out;
const auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return out;
const Json::Value* resp = doc->Find("Response");
if (!resp || !resp->IsArray()) return out;
for (const Json::Value& item : resp->array) {
if (!item.IsObject()) continue;
const Json::Value* p = item.Find("Payment");
if (!p || !p->IsObject()) continue;
const Json::Value* amount = p->Find("amount");
if (!amount || !amount->IsObject()) continue;
// Only euro amounts can pay a euro order. A foreign-currency credit is
// skipped rather than counted at face value, which would silently
// treat 25 of something else as 25 euro.
if (amount->Str("currency") != "EUR") continue;
const std::optional<std::int64_t> minor =
ParseSignedAmountToMinor(amount->Str("value"));
if (!minor) continue;
BankCredit c;
c.id = std::format("{}", p->Int("id"));
c.reference = std::string(p->Str("description"));
c.amountMinor = *minor;
c.method = std::string(BunqMethodFor(p->Str("type")));
out.push_back(std::move(c));
}
return out;
}
2026-08-20 23:33:50 +02:00
std::optional<BankCredit> ParseBunqCallback(std::string_view json) {
const auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
const Json::Value* note = doc->Find("NotificationUrl");
if (!note || !note->IsObject()) return std::nullopt;
const Json::Value* object = note->Find("object");
if (!object || !object->IsObject()) return std::nullopt;
const Json::Value* p = object->Find("Payment");
if (!p || !p->IsObject()) return std::nullopt;
const Json::Value* amount = p->Find("amount");
if (!amount || !amount->IsObject()) return std::nullopt;
// Only euro can pay a euro order; see ParseBunqPayments for why a
// foreign-currency credit is skipped rather than counted at face value.
if (amount->Str("currency") != "EUR") return std::nullopt;
const std::optional<std::int64_t> minor =
ParseSignedAmountToMinor(amount->Str("value"));
if (!minor) return std::nullopt;
BankCredit c;
c.id = std::format("{}", p->Int("id"));
c.reference = std::string(p->Str("description"));
c.amountMinor = *minor;
c.method = std::string(BunqMethodFor(p->Str("type")));
// An id is what deduplication rests on. A callback without one cannot be
// deduplicated, so accepting it would let a retry credit the same money
// twice — refuse instead.
if (c.id.empty() || c.id == "0") return std::nullopt;
return c;
}
2026-08-20 20:15:47 +02:00
namespace {
class BunqCreditSource final : public CreditSource {
public:
BunqCreditSource(std::string apiKey, std::filesystem::path statePath,
std::string permittedIps, int count, std::string wantIban)
: apiKey_(std::move(apiKey)), statePath_(std::move(statePath)),
permittedIps_(std::move(permittedIps)), count_(count),
wantIban_(std::move(wantIban)) {}
std::optional<std::vector<BankCredit>> Recent() override {
std::lock_guard lock(mutex_);
if (!EnsureSession()) return std::nullopt;
// One page is enough and more would be worse: the reconciler only ever
// asks about orders inside their payment window, so a credit old enough
// to fall off this page is old enough to be settled by hand anyway.
// Paging the whole account history every minute would also spend the
// per-method rate limit on data nothing reads.
const auto doc = Call("GET", std::format(
"/v1/user/{}/monetary-account/{}/payment?count={}", userId_, accountId_,
count_), {});
if (!doc) return std::nullopt;
return ParseBunqPayments(raw_);
}
std::string_view Name() const override { return "bunq"; }
private:
// ── state, so onboarding happens once ─────────────────────────────
void LoadState() {
std::ifstream in(statePath_, std::ios::binary);
if (!in) return;
std::ostringstream buf;
buf << in.rdbuf();
const auto doc = Json::Parse(buf.str());
if (!doc || !doc->IsObject()) return;
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
installationToken_ = std::string(doc->Str("installation_token"));
deviceRegistered_ = doc->Bool("device_registered");
sessionToken_ = std::string(doc->Str("session_token"));
userId_ = doc->Int("user_id");
accountId_ = doc->Int("account_id");
}
bool SaveState() {
std::ofstream out(statePath_, std::ios::trunc | std::ios::binary);
if (!out) {
std::println(std::cerr, "bunq: cannot write context {}", statePath_.string());
return false;
}
out << std::format(
R"({{"private_key_pem":"{}","installation_token":"{}",)"
R"("device_registered":{},"session_token":"{}","user_id":{},)"
R"("account_id":{}}})",
EscB(privateKeyPem_), EscB(installationToken_), deviceRegistered_,
EscB(sessionToken_), userId_, accountId_);
out.flush();
// The file holds a private key. Narrow it even though the directory
// should already be private: defence in depth costs one syscall.
std::error_code ec;
std::filesystem::permissions(statePath_,
std::filesystem::perms::owner_read
| std::filesystem::perms::owner_write,
ec);
return static_cast<bool>(out);
}
// ── the keypair bunq's installation call demands ──────────────────
bool EnsureKeypair() {
if (!privateKeyPem_.empty()) return LoadKey();
EVP_PKEY* raw = EVP_RSA_gen(2048);
if (!raw) return false;
key_.reset(raw);
BIO* bio = BIO_new(BIO_s_mem());
if (!bio) return false;
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0,
nullptr, nullptr) != 1) {
BIO_free(bio);
return false;
}
char* data = nullptr;
const long len = BIO_get_mem_data(bio, &data);
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
BIO_free(bio);
return SaveState();
}
bool LoadKey() {
if (key_) return true;
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
static_cast<int>(privateKeyPem_.size()));
if (!bio) return false;
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!raw) return false;
key_.reset(raw);
return true;
}
std::string PublicKeyPem() {
if (!LoadKey()) return {};
BIO* bio = BIO_new(BIO_s_mem());
if (!bio) return {};
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
BIO_free(bio);
return {};
}
char* data = nullptr;
const long len = BIO_get_mem_data(bio, &data);
std::string pem(data, static_cast<std::size_t>(len));
BIO_free(bio);
return pem;
}
// bunq stopped REQUIRING body signatures years ago, but the keypair has to
// exist for installation anyway and a signed request is valid whether or
// not the server checks, so every body is signed.
std::string SignBody(std::string_view body) {
if (!LoadKey()) return {};
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) return {};
std::string out;
do {
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr,
key_.get()) != 1) break;
std::size_t len = 0;
if (EVP_DigestSign(ctx, nullptr, &len,
reinterpret_cast<const unsigned char*>(body.data()),
body.size()) != 1) break;
std::vector<unsigned char> sig(len);
if (EVP_DigestSign(ctx, sig.data(), &len,
reinterpret_cast<const unsigned char*>(body.data()),
body.size()) != 1) break;
sig.resize(len);
out = Base64B(sig);
} while (false);
EVP_MD_CTX_free(ctx);
return out;
}
// ── transport ─────────────────────────────────────────────────────
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
const std::string& body,
const std::string& authToken,
std::string* statusOut = nullptr) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
kHost, static_cast<std::uint16_t>(443),
Crafter::TLSClientCredentials{});
}
Crafter::HTTPRequest req;
req.method = std::string(method);
req.path = path;
req.authority = kHost;
req.body = body;
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
req.headers["cache-control"] = "no-cache";
req.headers["x-bunq-client-request-id"] = RandomHexB(4);
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
req.headers["x-bunq-language"] = "en_US";
req.headers["x-bunq-region"] = "nl_NL";
if (!body.empty()) {
req.headers["content-type"] = "application/json";
const std::string sig = SignBody(body);
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
}
if (!authToken.empty()) {
req.headers["x-bunq-client-authentication"] = authToken;
}
const Crafter::HTTPResponse res = client_->Send(req);
if (statusOut) *statusOut = res.status;
if (res.status.size() != 3 || res.status[0] != '2') {
// 429 gets named, because the cure is different from every
// other failure: bunq allows only a few reads per second per
// method, and setup endpoints as few as ten per DAY.
if (res.status == "429") {
std::println(std::cerr,
"bunq: {} {} -> 429 rate limited; reads are capped at "
"a few per second and setup calls at ~10/day, so back "
"off rather than retrying in a loop", method, path);
} else {
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path,
res.status, res.body.substr(0, 200));
}
return std::nullopt;
}
// Kept so the caller can re-parse into domain types without this
// layer knowing about them.
raw_ = res.body;
auto doc = Json::Parse(res.body);
if (!doc) return std::nullopt;
return std::move(*doc);
} catch (const std::exception& e) {
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
client_.reset(); // dial fresh next time
return std::nullopt;
}
}
// A session call, with ONE automatic re-session on 401. Sessions expire
// server-side on a schedule the bunq app controls, so expiry is routine
// and must not surface as a payment failure.
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
const std::string& body) {
std::string status;
auto doc = DoCall(method, path, body, sessionToken_, &status);
if (!doc && status == "401") {
sessionToken_.clear();
if (!EnsureSession()) return std::nullopt;
doc = DoCall(method, path, body, sessionToken_, &status);
}
return doc;
}
// ── onboarding, each step at most once ────────────────────────────
bool EnsureSession() {
if (!loaded_) { LoadState(); loaded_ = true; }
if (apiKey_.empty()) {
std::println(std::cerr, "bunq: no API key configured");
return false;
}
if (!EnsureKeypair()) return false;
if (installationToken_.empty()) {
const std::string body = std::format(R"({{"client_public_key":"{}"}})",
EscB(PublicKeyPem()));
const auto doc = DoCall("POST", "/v1/installation", body, {});
if (!doc) return false;
const Json::Value* token = InResponse(*doc, "Token");
if (!token) return false;
installationToken_ = std::string(token->Str("token"));
if (installationToken_.empty()) return false;
SaveState();
}
if (!deviceRegistered_) {
// permitted_ips decides what a leaked key is worth. bunq has no
// read-only scope, so this key can move money: pinning it to the
// one address that should ever use it is the difference between a
// leak being survivable and being catastrophic. "*" is accepted
// but announced, because silently unpinning a payment-capable
// credential is exactly the kind of default nobody revisits.
if (permittedIps_ == "*") {
std::println(std::cerr,
"bunq: registering this device with permitted_ips=* — a "
"leaked key would then work from anywhere. Set "
"BUNQ_PERMITTED_IPS to this machine's egress address to "
"pin it (the registration is once-only, so changing it "
"later means deleting the context file).");
}
std::string ipList;
for (const auto part : std::views::split(permittedIps_, ',')) {
const std::string_view ip(part.begin(), part.end());
if (ip.empty()) continue;
if (!ipList.empty()) ipList += ",";
ipList += std::format("\"{}\"", EscB(ip));
}
if (ipList.empty()) ipList = "\"*\"";
const std::string body = std::format(
R"({{"description":"catcrafts.net credit reader","secret":"{}",)"
R"("permitted_ips":[{}]}})", EscB(apiKey_), ipList);
const auto doc = DoCall("POST", "/v1/device-server", body,
installationToken_);
if (!doc) return false;
deviceRegistered_ = true;
SaveState();
}
if (sessionToken_.empty() || userId_ == 0) {
const std::string body = std::format(R"({{"secret":"{}"}})", EscB(apiKey_));
const auto doc = DoCall("POST", "/v1/session-server", body,
installationToken_);
if (!doc) return false;
const Json::Value* token = InResponse(*doc, "Token");
if (!token) return false;
sessionToken_ = std::string(token->Str("token"));
// Which user object comes back depends on the account type, so try
// each rather than assuming this is a company account.
for (const std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
if (const Json::Value* u = InResponse(*doc, k)) {
userId_ = u->Int("id");
break;
}
}
if (sessionToken_.empty() || userId_ == 0) return false;
SaveState();
}
if (accountId_ == 0) {
const auto doc = Call("GET", std::format(
"/v1/user/{}/monetary-account?count=25", userId_), {});
if (!doc) return false;
const Json::Value* resp = doc->Find("Response");
if (!resp || !resp->IsArray()) return false;
// With more than one account, guessing is how the shop ends up
// reconciling against savings. Name the wanted one by IBAN when
// there is a choice, and refuse rather than pick.
std::vector<std::pair<std::int64_t, std::string>> active;
for (const Json::Value& item : resp->array) {
const Json::Value* acc = item.Find("MonetaryAccountBank");
if (!acc || !acc->IsObject()) continue;
if (acc->Str("status") != "ACTIVE") continue;
std::string iban;
if (const Json::Value* aliases = acc->Find("alias");
aliases && aliases->IsArray()) {
for (const Json::Value& a : aliases->array) {
if (a.IsObject() && a.Str("type") == "IBAN") {
iban = std::string(a.Str("value"));
break;
}
}
}
active.emplace_back(acc->Int("id"), std::move(iban));
}
if (active.empty()) {
std::println(std::cerr, "bunq: no active account found");
return false;
}
if (!wantIban_.empty()) {
for (const auto& [id, iban] : active) {
if (Matches(iban, wantIban_)) { accountId_ = id; break; }
}
if (accountId_ == 0) {
std::println(std::cerr,
"bunq: no active account matches TRANSFER_IBAN; the "
"key sees {} account(s)", active.size());
return false;
}
} else if (active.size() == 1) {
accountId_ = active.front().first;
} else {
std::println(std::cerr,
"bunq: this key sees {} active accounts and no "
"TRANSFER_IBAN was given to choose between them — "
"refusing to guess which one the shop is paid into",
active.size());
return false;
}
SaveState();
}
return true;
}
// IBANs are compared ignoring spacing and case: what a human pastes into
// configuration and what the API returns differ cosmetically far more
// often than they differ in substance.
static bool Matches(std::string_view a, std::string_view b) {
const auto fold = [](std::string_view s) {
std::string out;
for (const char c : s) {
if (c == ' ' || c == '\t') continue;
out += static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c);
}
return out;
};
return !a.empty() && fold(a) == fold(b);
}
struct PkeyDeleter {
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
};
static constexpr const char* kHost = "api.bunq.com";
std::string apiKey_;
std::filesystem::path statePath_;
std::string permittedIps_;
int count_ = 50;
std::string wantIban_;
std::mutex mutex_;
std::unique_ptr<Crafter::ClientHTTP1> client_;
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
std::string raw_;
bool loaded_ = false;
std::string privateKeyPem_;
std::string installationToken_;
bool deviceRegistered_ = false;
std::string sessionToken_;
std::int64_t userId_ = 0;
std::int64_t accountId_ = 0;
};
} // namespace
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config) {
if (config.apiKey.empty()) return nullptr;
if (config.statePath.empty()) {
std::println(std::cerr, "bunq: no context path given");
return nullptr;
}
return std::make_unique<BunqCreditSource>(
config.apiKey, config.statePath,
config.permittedIps.empty() ? std::string("*") : config.permittedIps,
config.count > 0 ? config.count : 50, config.iban);
}
} // namespace Catcrafts::Server