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

562 lines
23 KiB
C++
Raw Permalink Normal View History

2026-08-05 04:18:37 +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 payment rails: bunq (real money) and fake (tests).
//
// The bunq client speaks the v1 REST API over Crafter.Network's ClientHTTP1
// with TLS — no SDK, because the four calls this needs (installation,
// device-server, session-server, bunqme-tab) do not justify a dependency, and
// every byte in and out goes through the same strict JSON reader as the rest
// of the site.
//
// Context (RSA key, installation token, session token, ids) persists in ONE
// JSON file under the service's StateDirectory — never in the repo, never in
// the web root. Delete the file and the client re-onboards from the API key.
//
// A word on trust direction: this code never treats an inbound signal as
// authoritative. The ?status= on bunq's redirect back to the order page is
// ignored entirely; an order becomes paid ONLY when an authenticated GET to
// bunq's API says the tab's payments cover the amount. That is the poll — the
// reconciler in Catcrafts.Server-Http.cpp drives it.
//
// Request signing: bunq stopped REQUIRING body signatures in 2019, but the
// keypair exists anyway (installation demands a public key), signing is ~40
// lines, and a signed request is valid whether or not the server checks. So
// every body is signed — X-Bunq-Client-Signature, RSA-SHA256 over the raw
// body, base64.
module;
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
namespace {
// ── small pure helpers ────────────────────────────────────────────────
std::string Base64(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 JsonEscapeB(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 RandomHex(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;
}
// Find the first object under any key in bunq's Response array:
// {"Response":[{"Id":{...}},{"Token":{...}}]}
const Json::Value* FindInResponse(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> ParseAmountToMinor(std::string_view s) {
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
// spaces, thousands separators — is rejected. Money parsing has no
// "probably fine" mode.
if (s.empty() || s.size() > 15) return std::nullopt;
std::int64_t units = 0;
std::size_t i = 0;
if (s[i] < '0' || s[i] > '9') return std::nullopt;
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
units = units * 10 + (s[i] - '0');
}
std::int64_t cents = 0;
if (i < s.size()) {
if (s[i] != '.') return std::nullopt;
++i;
const std::size_t fracStart = i;
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
cents = cents * 10 + (s[i] - '0');
}
const std::size_t digits = i - fracStart;
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
if (digits == 1) cents *= 10;
}
return units * 100 + cents;
}
namespace {
// ── the fake rail ─────────────────────────────────────────────────────
//
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
// reconciler, paid transition — runs in e2e with zero network. Payment links
// point at a made-up URL; CheckPaid answers true once a marker file exists,
// which the test creates when it wants "the customer has paid" to happen.
class FakeRail final : public PaymentRail {
public:
explicit FakeRail(std::filesystem::path marker) : marker_(std::move(marker)) {}
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
const std::string& redirectUrl) override {
static std::atomic<std::int64_t> counter{1};
PaymentLink link;
link.payId = std::format("fake-{}", counter.fetch_add(1));
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
// send anyone to, so it points at the order page itself — which keeps
// the browser flow usable in dev and the e2e redirect parseable.
link.payUrl = redirectUrl;
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
std::error_code ec;
return PaidStatus{
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
"fake" };
}
std::string_view Name() const override { return "fake"; }
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
private:
std::filesystem::path marker_;
};
// ── the bunq rail ─────────────────────────────────────────────────────
class BunqRail final : public PaymentRail {
public:
explicit BunqRail(RailConfig cfg)
: cfg_(std::move(cfg)),
host_(cfg_.sandbox ? "public-api.sandbox.bunq.com" : "api.bunq.com") {}
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
const std::string& description,
const std::string& redirectUrl) override {
std::lock_guard lock(mutex_);
if (!EnsureSession()) return std::nullopt;
const std::string body = std::format(
R"({{"bunqme_tab_entry":{{"amount_inquired":{{"value":"{}","currency":"EUR"}},)"
R"("description":"{}","redirect_url":"{}"}}}})",
Money::FormatMinor(amountMinor), JsonEscapeB(description),
JsonEscapeB(redirectUrl));
auto doc = Call("POST", TabsPath(), body);
if (!doc) return std::nullopt;
const Json::Value* id = FindInResponse(*doc, "Id");
if (!id) return std::nullopt;
const std::int64_t tabId = id->Int("id");
if (tabId <= 0) return std::nullopt;
// The POST answers with the id only; the share URL comes from a GET.
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
if (!tab) return std::nullopt;
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
if (!bmt) return std::nullopt;
const std::string url(bmt->Str("bunqme_tab_share_url"));
if (url.empty()) return std::nullopt;
PaymentLink link;
link.payId = std::to_string(tabId);
link.payUrl = url;
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_);
// The id is a bunq tab number that travelled through our ledger.
std::int64_t tabId = 0;
auto [ptr, ec] = std::from_chars(payId.data(), payId.data() + payId.size(), tabId);
if (ec != std::errc{} || ptr != payId.data() + payId.size() || tabId <= 0) {
return PaidStatus{ PayState::Dead, {} };
}
if (!EnsureSession()) return std::nullopt;
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
if (!tab) return std::nullopt;
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
if (!bmt) return std::nullopt;
// Sum every settled inquiry on the tab. A tab accepts unlimited
// payments until cancelled, so the question is "do the payments cover
// the amount", not "is there a payment".
std::int64_t paid = 0;
if (const Json::Value* inquiries = bmt->Find("result_inquiries");
inquiries && inquiries->IsArray()) {
for (const Json::Value& entry : inquiries->array) {
if (!entry.IsObject()) continue;
const Json::Value* payment = entry.Find("payment");
if (payment && payment->IsObject()) {
if (const Json::Value* inner = payment->Find("Payment");
inner && inner->IsObject()) payment = inner;
}
if (!payment) continue;
const Json::Value* amount = payment->Find("amount");
if (!amount || !amount->IsObject()) continue;
if (amount->Str("currency") != "EUR") continue;
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
paid += *minor;
}
}
}
// A bunq tab never dies on its own — it accepts payments until
// cancelled — so the only states here are Paid and Pending. The
// method is not identified per payment; "bunq" is honest enough.
return PaidStatus{ paid >= expectedMinor ? PayState::Paid : PayState::Pending,
"bunq" };
}
std::string_view Name() const override { return "bunq"; }
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(15); }
private:
// ── context persistence ───────────────────────────────────────────
void LoadState() {
std::ifstream in(cfg_.statePath, std::ios::binary);
if (!in) return;
std::ostringstream buf;
buf << in.rdbuf();
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() {
// 0600 before content: the file holds the private key.
std::ofstream out(cfg_.statePath, std::ios::trunc | std::ios::binary);
if (!out) return false;
out << std::format(
R"({{"private_key_pem":"{}","installation_token":"{}",)"
R"("device_registered":{},"session_token":"{}","user_id":{},"account_id":{}}})",
JsonEscapeB(privateKeyPem_), JsonEscapeB(installationToken_),
deviceRegistered_, JsonEscapeB(sessionToken_), userId_, accountId_);
out.flush();
std::error_code ec;
std::filesystem::permissions(cfg_.statePath,
std::filesystem::perms::owner_read
| std::filesystem::perms::owner_write,
ec);
return static_cast<bool>(out);
}
// ── crypto ────────────────────────────────────────────────────────
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;
}
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 = Base64(sig);
} while (false);
EVP_MD_CTX_free(ctx);
return out;
}
// ── transport ─────────────────────────────────────────────────────
// One HTTPS call, returning the parsed JSON on 2xx. On 401 with a live
// session the caller decides whether to re-session; this layer only
// reports. Network and TLS failures land as nullopt — the reconciler
// treats that as "unknown, retry later", never as "unpaid".
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>(
host_, static_cast<std::uint16_t>(443),
Crafter::TLSClientCredentials{});
}
Crafter::HTTPRequest req;
req.method = std::string(method);
req.path = path;
req.authority = host_;
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"] = RandomHex(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') {
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path, res.status,
res.body.substr(0, 200));
return std::nullopt;
}
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-authenticated call, with one automatic re-session on 401 —
// sessions expire server-side and that must not surface as a 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 ────────────────────────────────────────────────────
//
// 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) -> account to attach tabs to
//
// All idempotent to re-run individually; state records how far we got.
bool EnsureSession() {
if (!loaded_) { LoadState(); loaded_ = true; }
if (cfg_.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":"{}"}})", JsonEscapeB(PublicKeyPem()));
auto doc = DoCall("POST", "/v1/installation", body, {});
if (!doc) return false;
const Json::Value* token = FindInResponse(*doc, "Token");
if (!token) return false;
installationToken_ = std::string(token->Str("token"));
if (installationToken_.empty()) return false;
SaveState();
}
if (!deviceRegistered_) {
// permitted_ips "*": this box sits on a residential connection
// whose address changes; pinning the current IP would brick the
// integration on the next DHCP lease. The API key secret still
// gates everything.
const std::string body = std::format(
R"({{"description":"catcrafts.net server","secret":"{}","permitted_ips":["*"]}})",
JsonEscapeB(cfg_.apiKey));
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":"{}"}})", JsonEscapeB(cfg_.apiKey));
auto doc = DoCall("POST", "/v1/session-server", body, installationToken_);
if (!doc) return false;
const Json::Value* token = FindInResponse(*doc, "Token");
if (!token) return false;
sessionToken_ = std::string(token->Str("token"));
// The user object's key varies by account type; take whichever came.
for (std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
if (const Json::Value* u = FindInResponse(*doc, k)) {
userId_ = u->Int("id");
break;
}
}
if (sessionToken_.empty() || userId_ == 0) return false;
SaveState();
}
if (accountId_ == 0) {
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;
for (const Json::Value& item : resp->array) {
const Json::Value* acc = item.Find("MonetaryAccountBank");
if (acc && acc->Str("status") == "ACTIVE") {
accountId_ = acc->Int("id");
break;
}
}
if (accountId_ == 0) {
std::println(std::cerr, "bunq: no active MonetaryAccountBank found");
return false;
}
SaveState();
}
return true;
}
std::string TabsPath() const {
return std::format("/v1/user/{}/monetary-account/{}/bunqme-tab", userId_, accountId_);
}
struct PkeyDeleter {
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
};
RailConfig cfg_;
std::string host_;
std::mutex mutex_;
std::unique_ptr<Crafter::ClientHTTP1> client_;
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
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
// The roster itself (MakeRail) lives in the Mollie unit; these two factories
// keep FakeRail/BunqRail construction next to their definitions.
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config) {
return std::make_unique<FakeRail>(config.statePath);
}
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config) {
return std::make_unique<BunqRail>(config);
}
} // namespace Catcrafts::Server