/* 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 #include #include module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; namespace Catcrafts::Server { namespace { std::string Base64B(std::span 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(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(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 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 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 ParseBunqPayments(std::string_view json) { std::vector 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 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; } std::optional 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 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; } 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> 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(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(len)); BIO_free(bio); return SaveState(); } bool LoadKey() { if (key_) return true; BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(), static_cast(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(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(body.data()), body.size()) != 1) break; std::vector sig(len); if (EVP_DigestSign(ctx, sig.data(), &len, reinterpret_cast(body.data()), body.size()) != 1) break; sig.resize(len); out = Base64B(sig); } while (false); EVP_MD_CTX_free(ctx); return out; } // ── transport ───────────────────────────────────────────────────── std::optional 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( kHost, static_cast(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 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> 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(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 client_; std::unique_ptr 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 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( config.apiKey, config.statePath, config.permittedIps.empty() ? std::string("*") : config.permittedIps, config.count > 0 ? config.count : 50, config.iban); } } // namespace Catcrafts::Server