diff --git a/Cargo.lock b/Cargo.lock index e017485..430cb17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,23 +6,54 @@ version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b602bfe940d21c130f3895acd65221e8a61270debe89d628b9cb4e3ccb8569b" +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff77d8686867eceff3105329d4698d96c2391c176d5d03adc90c7389162b5b8" + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "blake2b_simd" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fb2d74254a3a0b5cac33ac9f8ed0e44aa50378d9dbb2e5d83bd21ed1dc2c8a" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "bunqledger" version = "0.1.0" dependencies = [ "anyhow", + "base64", + "confy", "dotenv", "isahc", "openssl", @@ -48,6 +79,23 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +[[package]] +name = "confy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2913470204e9e8498a0f31f17f90a0de801ae92c8c5ac18c49af4819e6786697" +dependencies = [ + "directories", + "serde", + "toml", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "crossbeam-channel" version = "0.4.4" @@ -100,6 +148,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "directories" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551a778172a450d7fc12e629ca3b0428d00f6afa9a43da1b630d54604e97371c" +dependencies = [ + "cfg-if", + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -181,6 +250,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc587bc0ec293155d5bfa6b9891ec18a1e330c234f896ea47fbada4cadbe47e6" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "http" version = "0.2.1" @@ -381,6 +461,29 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom", + "redox_syscall", + "rust-argon2", +] + +[[package]] +name = "rust-argon2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dab61250775933275e84053ac235621dfb739556d5c54a2f2e9313b7cf43a19" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "ryu" version = "1.0.5" @@ -469,6 +572,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "toml" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc92d160b1eef40665be3a05630d003936a3bc7da7421277846c2613e92c71a" +dependencies = [ + "serde", +] + [[package]] name = "tracing" version = "0.1.19" @@ -523,6 +635,12 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6454029bf181f092ad1b853286f23e2c507d8e8194d01d92da4a55c274a5508c" +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 5e14e74..0477933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,5 @@ dotenv = "0.15.0" openssl = "0.10.30" serde_json = "1.0.57" serde = { version = "1.0.116", features = ["derive"] } +base64 = "0.12.3" +confy = "0.4.0" diff --git a/src/main.rs b/src/main.rs index 79eb2bb..2009364 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,304 @@ use anyhow::Result; use isahc::prelude::*; -use serde::Serialize; +use openssl::hash::MessageDigest; use openssl::pkey::PKey; +use openssl::rsa::Rsa; +use openssl::sign::{Signer, Verifier}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; -const BASE: &str = "https://public-api.sandbox.bunq.com/v1/"; +const BASE: &str = "https://api.bunq.com"; #[derive(Serialize)] struct Installation<'a> { client_public_key: &'a str, } +#[derive(Deserialize)] +struct Token { + token: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct InstallationResponse { + token: Token, +} + +#[derive(Serialize)] +struct DeviceServer<'a> { + description: &'a str, + secret: &'a str, + permitted_ips: &'a [&'a str], +} + +#[derive(Serialize)] +struct SessionServer<'a> { + secret: &'a str, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct SessionServerResponse { + token: Token, + user_person: UserPerson, +} + +#[derive(Deserialize)] +struct UserPerson { + id: i64, +} + +fn sign(body: &str, key: &PKey) -> Result { + let mut signer = Signer::new(MessageDigest::sha256(), key)?; + + let sig = signer.sign_oneshot_to_vec(body.as_bytes())?; + + Ok(base64::encode(&sig)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawResponse { + response: Vec, + pagination: Option, +} + +struct Response { + response: T, + pagination: Option, +} + +impl RawResponse { + fn decode_retarded(self) -> Result> { + let mut map = serde_json::Map::new(); + for e in self.response { + if let serde_json::Value::Object(e) = e { + let (k, v) = e + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("malformed response"))?; + map.insert(k, v); + } + } + Ok(Response { + response: serde_json::from_value(map.into())?, + pagination: self.pagination, + }) + } +} + +fn deserialize_retarded_response(r: &str) -> Result> { + let r: RawResponse = serde_json::from_str(r)?; + r.decode_retarded() +} + +fn deserialize_normal_response(r: &str) -> Result> { + let r: RawResponse = serde_json::from_str(r)?; + Ok(Response { + response: serde_json::from_value(r.response.into())?, + pagination: r.pagination, + }) +} + +#[derive(Serialize, Deserialize, Default)] +struct AppState { + token: String, + pem_private: String, +} +#[derive(Serialize, Deserialize, Default)] +struct AppConfig { + api_key: String, + state: Option, +} +struct AppConfigReady { + api_key: String, + token: String, + keypair: PKey, + user_id: i64, +} +impl AppConfig { + pub fn load() -> Result { + Ok(confy::load("bunqledger")?) + } + pub fn save(&self) -> Result<()> { + confy::store("bunqledger", self)?; + Ok(()) + } + pub fn install(mut self) -> Result { + 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(AppConfigReady { + api_key: self.api_key, + keypair, + token: r.token.token, + user_id: r.user_person.id, + }) + } +} + +impl AppConfigReady { + pub fn monetary_accounts(&self) -> Result> { + 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::>(&response)? + .response + .into_iter() + .map(|m| m.monetary_account_bank) + .collect(), + ) + } + pub fn payments(&self, acc: &MonetaryAccountBank) -> Result> { + 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::>(&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 + ); + let mut all = Vec::new(); + loop { + let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?; + all.append(&mut payments); + if let Some(Pagination{older_url: Some(older_url),..}) = pag { + url = older_url; + } else { + break; + } + } + Ok(all) + } +} + +#[derive(Deserialize, Debug)] +struct LabelMonetaryAccount { + iban: Option, + display_name: String, + merchant_category_code: Option, +} + +#[derive(Deserialize, Debug)] +struct Amount { + value: String, + currency: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct PaymentPayment { + payment: Payment, +} + +#[derive(Deserialize, Debug)] +struct Pagination { + future_url: Option, + newer_url: Option, + older_url: Option, +} + +#[derive(Deserialize, Debug)] +struct Payment { + alias: LabelMonetaryAccount, + counterparty_alias: LabelMonetaryAccount, + amount: Amount, + balance_after_mutation: Amount, + created: String, + updated: String, + description: String, + id: i64, + monetary_account_id: i64, + #[serde(rename = "type")] + type_: String, + sub_type: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct MonetaryAccount { + monetary_account_bank: MonetaryAccountBank, +} +#[derive(Deserialize, Debug)] +struct MonetaryAccountBank { + id: i64, + description: String, +} + fn main() -> Result<()> { - dotenv::dotenv()?; + let cfg = AppConfig::load()?; - let api_key = std::env::var("API_KEY")?; + let cfg = cfg.install()?; + let accs = cfg.monetary_accounts()?; + println!("{:#?}", accs); + let acc = &accs[0]; + let ps = cfg.payments(acc)?; + println!("{:#?}", ps); - let pem_private = include_bytes!("../private.pem"); - let pem_public = include_bytes!("../public.pem"); - let keypair = PKey::private_key_from_pem(pem_private)?; - - let client_public_key = std::str::from_utf8(pem_public)?; - let body = Installation { client_public_key: &client_public_key}; - let mut response = isahc::post(format!("{}installation",BASE), serde_json::to_string(&body)?)?; - println!("{}", response.text()?); Ok(()) }