Compare commits

..

6 Commits

Author SHA1 Message Date
4d347a657c Update all dependencies to the latest versions
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 21:59:45 +01:00
54cec9ac04 Refactor to use one module per concept
This makes code navigation easier.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 21:41:11 +01:00
2874d8dba0 Add a README.md file
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 20:39:49 +01:00
7bbcc3b99b Update confy to show the configuration path
With the older version (v0.4.0) there was no possibility to show the
configuration path to the user, who therefore had to hunt for its
location.

By updating it, this path can now be shown during loading and storing.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:53:21 +01:00
95437f3549 A monetary acocunt can also be a savings account
So update the code to deal with that.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:53:12 +01:00
096ccec741 Use crate rsa instead of openssl
The rsa crate contains a pure rust implementation of the same
primitives.

This helps with cross platform support, such as Windows.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:20:24 +01:00
8 changed files with 351 additions and 265 deletions

View File

@ -10,11 +10,12 @@ edition = "2018"
[dependencies] [dependencies]
anyhow = "1.0.32" anyhow = "1.0.79"
isahc = { version = "0.9.8", features = ["json"] } base64 = "0.21.7"
confy = "0.6.0"
dotenv = "0.15.0" dotenv = "0.15.0"
openssl = "0.10.30" isahc = { version = "1.7.2", features = ["json"] }
serde_json = "1.0.57" rand = "0.8.5"
serde = { version = "1.0.116", features = ["derive"] } rsa = { version = "0.9.6", features = ["sha2"] }
base64 = "0.12.3" serde = { version = "1.0.196", features = ["derive"] }
confy = "0.4.0" serde_json = "1.0.113"

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# bunq-rs
bunq-rs is a Rust wrapper around the [bunq API][0].
It is configured (with [confy][1]) by creating a `bunq-rs.toml` file in
the OS configuration directory, and with the following contents:
```yaml
api_key = '<bunq API key>'
```
The api will take care to update this file with all the requisite information.
[0]: https://doc.bunq.com/
[1]: https://docs.rs/confy/

View File

@ -5,8 +5,10 @@ fn main() -> Result<()> {
let cfg = BunqConfig::load()?; let cfg = BunqConfig::load()?;
let cfg = cfg.install()?; let cfg = cfg.install()?;
let accs = cfg.monetary_accounts()?; let accs = cfg.monetary_accounts()?;
println!("{:#?}", accs); println!("{:#?}", accs);
let acc = &accs[0]; let acc = &accs[0];
let ps = cfg.payments(acc)?; let ps = cfg.payments(acc)?;
println!("{:#?}", ps); println!("{:#?}", ps);

138
src/config.rs Normal file
View File

@ -0,0 +1,138 @@
use anyhow::anyhow;
use isahc::{ReadResponseExt, RequestExt};
use isahc::http::StatusCode;
use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, LineEnding};
use rsa::pkcs8::EncodePublicKey;
use rsa::RsaPrivateKey;
use serde::{Deserialize, Serialize};
use crate::{BASE_URL, BunqClient, deserialize_retarded_response, sign};
#[derive(Serialize, Deserialize, Default)]
struct AppState {
token: String,
pem_private: String,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BunqConfig {
api_key: String,
state: Option<AppState>,
}
impl BunqConfig {
pub fn load() -> anyhow::Result<BunqConfig> {
println!("Loading config file from {}", confy::get_configuration_file_path("bunq-rs", "bunq-rs")?.to_string_lossy());
Ok(confy::load("bunq-rs", "bunq-rs")?)
}
pub fn save(&self) -> anyhow::Result<()> {
println!("Storing config file in {}", confy::get_configuration_file_path("bunq-rs", None)?.to_string_lossy());
confy::store("bunq-rs", "bunq-rs", self)?;
Ok(())
}
pub fn install(mut self) -> anyhow::Result<BunqClient> {
let api_key = &self.api_key;
let keypair = if let Some(state) = &self.state {
RsaPrivateKey::from_pkcs1_pem(&state.pem_private)?
} else {
let mut rng = rand::thread_rng();
let bits = 2048;
let keypair = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
let pem_public = keypair.to_public_key().to_public_key_pem(LineEnding::CRLF)?;
let body = Installation {
client_public_key: &pem_public,
};
let response = isahc::post(
format!("{}/v1/installation", BASE_URL),
serde_json::to_string(&body)?,
)?
.text()?;
let response: InstallationResponse = deserialize_retarded_response(&response)?.response;
let token = response.token.token;
let body = DeviceServer {
description: "JK Test Server",
secret: api_key,
permitted_ips: &["77.175.86.236", "*"],
};
let body = serde_json::to_string(&body)?;
let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE_URL))
.header("X-Bunq-Client-Authentication", &token)
.body(body)?
.send()?;
let response_text = response.text()?;
println!("{}", response_text);
if response.status() != StatusCode::OK {
return Err(anyhow!(response_text));
}
self.state = Some(AppState { pem_private: keypair.to_pkcs1_pem(LineEnding::CRLF)?.to_string(), token });
self.save()?;
keypair
};
let token = self.state.unwrap().token;
let body = SessionServer { secret: api_key };
let body = serde_json::to_string(&body)?;
let sig = sign(&body, &keypair)?;
let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE_URL))
.header("X-Bunq-Client-Authentication", &token)
.header("X-Bunq-Client-Signature", &sig)
.body(body)?
.send()?
.text()?;
let r: SessionServerResponse = deserialize_retarded_response(&response)?.response;
Ok(BunqClient {
keypair,
token: r.token.token,
user_id: r.user_person.id,
})
}
}
#[derive(Serialize)]
pub(super) struct Installation<'a> {
pub(super) client_public_key: &'a str,
}
#[derive(Deserialize)]
pub(super) struct Token {
pub(super) token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct InstallationResponse {
pub(super) token: Token,
}
#[derive(Serialize)]
pub(super) struct DeviceServer<'a> {
pub(super) description: &'a str,
pub(super) secret: &'a str,
pub(super) permitted_ips: &'a [&'a str],
}
#[derive(Serialize)]
pub(super) struct SessionServer<'a> {
pub(super) secret: &'a str,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct SessionServerResponse {
pub(super) token: Token,
pub(super) user_person: UserPerson,
}
#[derive(Deserialize)]
pub(super) struct UserPerson {
pub(super) id: i64,
}

0
src/installation.rs Normal file
View File

View File

@ -1,58 +1,57 @@
use anyhow::Result; use anyhow::Result;
use isahc::prelude::*; use rsa::pkcs1v15::SigningKey;
use openssl::hash::MessageDigest; use rsa::RsaPrivateKey;
use openssl::pkey::PKey; use rsa::sha2::Sha256;
use openssl::rsa::Rsa; use rsa::signature::RandomizedSigner;
use openssl::sign::Signer; use serde::{de::DeserializeOwned, Deserialize};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
const BASE: &str = "https://api.bunq.com"; pub use config::BunqConfig;
pub use monetary_account::MonetaryAccount;
pub use payment::Payment;
#[derive(Serialize)] mod config;
struct Installation<'a> { mod monetary_account;
client_public_key: &'a str, mod payment;
}
#[derive(Deserialize)] const BASE_URL: &str = "https://api.bunq.com";
struct Token {
pub struct BunqClient {
token: String, token: String,
} #[allow(dead_code)] // Required for signing bodies. Not used yet.
#[derive(Deserialize)] keypair: RsaPrivateKey,
#[serde(rename_all = "PascalCase")] user_id: i64,
struct InstallationResponse {
token: Token,
} }
#[derive(Serialize)] impl BunqClient {
struct DeviceServer<'a> { pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccount>> {
description: &'a str, monetary_account::get(self)
secret: &'a str, }
permitted_ips: &'a [&'a str],
pub fn payments(&self, acc: &MonetaryAccount) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>,
) -> anyhow::Result<Vec<Payment>> {
payment::get_from_to(self, acc, from, to)
}
} }
#[derive(Serialize)] #[derive(Deserialize, Debug)]
struct SessionServer<'a> { struct Pagination {
secret: &'a str, #[allow(dead_code)] // Used for refresh. Not implemented yet.
future_url: Option<String>,
#[allow(dead_code)] // Used for finding newer items. Not necessary yet.
newer_url: Option<String>,
older_url: Option<String>,
} }
#[derive(Deserialize)] struct Response<T> {
#[serde(rename_all = "PascalCase")] response: T,
struct SessionServerResponse { pagination: Option<Pagination>,
token: Token,
user_person: UserPerson,
}
#[derive(Deserialize)]
struct UserPerson {
id: i64,
}
fn sign<K: openssl::pkey::HasPrivate>(body: &str, key: &PKey<K>) -> Result<String> {
let mut signer = Signer::new(MessageDigest::sha256(), key)?;
let sig = signer.sign_oneshot_to_vec(body.as_bytes())?;
Ok(base64::encode(&sig))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -62,11 +61,6 @@ struct RawResponse {
pagination: Option<Pagination>, pagination: Option<Pagination>,
} }
struct Response<T> {
response: T,
pagination: Option<Pagination>,
}
impl RawResponse { impl RawResponse {
fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> { fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
@ -99,213 +93,11 @@ fn deserialize_normal_response<T: DeserializeOwned>(r: &str) -> Result<Response<
}) })
} }
#[derive(Serialize, Deserialize, Default)] fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> {
struct AppState { use base64::prelude::{BASE64_STANDARD, Engine};
token: String,
pem_private: String, let signing_key = SigningKey::<Sha256>::new(key.clone());
} let mut rng = rand::thread_rng();
#[derive(Serialize, Deserialize, Default)] let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes());
pub struct BunqConfig { Ok(BASE64_STANDARD.encode(signature.to_string()))
api_key: String,
state: Option<AppState>,
}
pub struct BunqConfigReady {
token: String,
keypair: PKey<openssl::pkey::Private>,
user_id: i64,
}
impl BunqConfig {
pub fn load() -> Result<BunqConfig> {
Ok(confy::load("bunq-rs")?)
}
pub fn save(&self) -> Result<()> {
confy::store("bunq-rs", self)?;
Ok(())
}
pub fn install(mut self) -> Result<BunqConfigReady> {
let api_key = &self.api_key;
let keypair = if let Some(state) = &self.state {
PKey::private_key_from_pem(state.pem_private.as_bytes())?
} else {
let rsa = Rsa::generate(2048)?;
let pem_private = rsa.private_key_to_pem()?;
let pem_private = String::from_utf8(pem_private)?;
let keypair = PKey::from_rsa(rsa)?;
let pem_public = String::from_utf8(keypair.public_key_to_pem()?)?;
let body = Installation {
client_public_key: &pem_public,
};
let response = isahc::post(
format!("{}/v1/installation", BASE),
serde_json::to_string(&body)?,
)?
.text()?;
let response: InstallationResponse = deserialize_retarded_response(&response)?.response;
let token = response.token.token;
let body = DeviceServer {
description: "awesome",
secret: &api_key,
permitted_ips: &["31.21.118.143", "*"],
};
let body = serde_json::to_string(&body)?;
let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE))
.header("X-Bunq-Client-Authentication", &token)
.body(body)?
.send()?;
println!("{}", response.text()?);
self.state = Some(AppState { pem_private, token });
self.save()?;
keypair
};
let token = self.state.unwrap().token;
let body = SessionServer { secret: &api_key };
let body = serde_json::to_string(&body)?;
let sig = sign(&body, &keypair)?;
let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE))
.header("X-Bunq-Client-Authentication", &token)
.header("X-Bunq-Client-Signature", &sig)
.body(body)?
.send()?
.text()?;
let r: SessionServerResponse = deserialize_retarded_response(&response)?.response;
Ok(BunqConfigReady {
keypair,
token: r.token.token,
user_id: r.user_person.id,
})
}
}
impl BunqConfigReady {
pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccountBank>> {
let response = isahc::http::Request::get(format!(
"{}/v1/user/{}/monetary-account",
BASE, self.user_id
))
.header("X-Bunq-Client-Authentication", &self.token)
.body(())?
.send()?
.text()?;
Ok(
deserialize_normal_response::<Vec<MonetaryAccount>>(&response)?
.response
.into_iter()
.map(|m| m.monetary_account_bank)
.collect(),
)
}
pub fn payments(&self, acc: &MonetaryAccountBank) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccountBank,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<Payment>> {
let next_page = |url: &str| -> Result<(_, _)> {
let response = isahc::http::Request::get(url)
.header("X-Bunq-Client-Authentication", &self.token)
.body(())?
.send()?
.text()?;
let Response {
response,
pagination,
} = deserialize_normal_response::<Vec<PaymentPayment>>(&response)?;
Ok((
response.into_iter().map(|p| p.payment).collect(),
pagination,
))
};
let mut url = format!(
"/v1/user/{}/monetary-account/{}/payment",
self.user_id, acc.id
);
if let Some(to) = to {
url = format!("{}?newer_id={}", url, to);
}
let mut all = Vec::new();
loop {
let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?;
all.append(&mut payments);
dbg!(&pag);
if let Some(Pagination {
older_url: Some(older_url),
..
}) = pag
{
if let (Some(latest), Some(from)) = (all.last(), from) {
if latest.id <= from {
all = all.into_iter().filter(|p| p.id >= from).collect();
break;
}
}
url = older_url;
} else {
break;
}
}
Ok(all)
}
}
#[derive(Deserialize, Debug)]
pub struct LabelMonetaryAccount {
pub iban: Option<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Amount {
pub value: String,
pub currency: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct PaymentPayment {
payment: Payment,
}
#[derive(Deserialize, Debug)]
struct Pagination {
future_url: Option<String>,
newer_url: Option<String>,
older_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Payment {
pub alias: LabelMonetaryAccount,
pub counterparty_alias: LabelMonetaryAccount,
pub amount: Amount,
pub balance_after_mutation: Amount,
pub created: String,
pub updated: String,
pub description: String,
pub id: i64,
pub monetary_account_id: i64,
#[serde(rename = "type")]
pub type_: String,
pub sub_type: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MonetaryAccount {
monetary_account_bank: MonetaryAccountBank,
}
#[derive(Deserialize, Debug)]
pub struct MonetaryAccountBank {
pub id: i64,
pub description: String,
} }

38
src/monetary_account.rs Normal file
View File

@ -0,0 +1,38 @@
use isahc::{ReadResponseExt, RequestExt};
use serde::Deserialize;
use crate::{BASE_URL, BunqClient};
pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> {
let response = isahc::http::Request::get(format!(
"{}/v1/user/{}/monetary-account",
BASE_URL, client.user_id
))
.header("X-Bunq-Client-Authentication", &client.token)
.body(())?
.send()?
.text()?;
let accounts = crate::deserialize_normal_response::<Vec<MonetaryAccount>>(&response)?;
Ok(accounts.response)
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
pub enum MonetaryAccount {
MonetaryAccountBank(MonetaryAccountBank),
MonetaryAccountSavings(MonetaryAccountSavings),
}
#[derive(Deserialize, Debug)]
pub struct MonetaryAccountBank {
pub id: i64,
pub description: String,
}
#[derive(Deserialize, Debug)]
pub struct MonetaryAccountSavings {
pub id: i64,
pub description: String,
}

100
src/payment.rs Normal file
View File

@ -0,0 +1,100 @@
use anyhow::Result;
use isahc::{ReadResponseExt, RequestExt};
use serde::Deserialize;
use crate::{BASE_URL, BunqClient, MonetaryAccount, Pagination, Response};
pub(super) fn get_from_to(client: &BunqClient,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>) -> Result<Vec<Payment>>
{
let next_page = |url: &str| -> Result<(_, _)> {
let response = isahc::http::Request::get(url)
.header("X-Bunq-Client-Authentication", &client.token)
.body(())?
.send()?
.text()?;
let Response {
response,
pagination,
} = crate::deserialize_normal_response::<Vec<PaymentPayment>>(&response)?;
Ok((
response.into_iter().map(|p| p.payment).collect(),
pagination,
))
};
let account_id = match acc {
MonetaryAccount::MonetaryAccountBank(bank) => bank.id,
MonetaryAccount::MonetaryAccountSavings(savings) => savings.id,
};
let mut url = format!(
"/v1/user/{}/monetary-account/{}/payment",
client.user_id, account_id
);
if let Some(to) = to {
url = format!("{}?newer_id={}", url, to);
}
let mut all = Vec::new();
loop {
let (mut payments, pag) = next_page(&format!("{}{}", BASE_URL, url))?;
dbg!(&payments);
all.append(&mut payments);
dbg!(&pag);
if let Some(Pagination {
older_url: Some(older_url),
..
}) = pag
{
if let (Some(latest), Some(from)) = (all.last(), from) {
if latest.id <= from {
all.retain(|p| p.id >= from);
break;
}
}
url = older_url;
} else {
break;
}
}
Ok(all)
}
#[derive(Deserialize, Debug)]
pub struct LabelMonetaryAccount {
pub iban: Option<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Amount {
pub value: String,
pub currency: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct PaymentPayment {
payment: Payment,
}
#[derive(Deserialize, Debug)]
pub struct Payment {
pub alias: LabelMonetaryAccount,
pub counterparty_alias: LabelMonetaryAccount,
pub amount: Amount,
pub balance_after_mutation: Amount,
pub created: String,
pub updated: String,
pub description: String,
pub id: i64,
pub monetary_account_id: i64,
#[serde(rename = "type")]
pub type_: String,
pub sub_type: String,
}