taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 9bcaedd00e7f52bd798944896801322b3920f803
parent 9f177f8367f1ee83cf99fc3c3ead7295e6037510
Author: Antoine A <>
Date:   Wed,  8 Jan 2025 22:48:25 +0100

magnet-bank: more setup work

Diffstat:
M.gitignore | 5+++--
MCargo.lock | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acommon/taler-common/src/json_file.rs | 33+++++++++++++++++++++++++++++++++
Mcommon/taler-common/src/lib.rs | 1+
Mwire-gateway/magnet-bank/Cargo.toml | 7+++++--
Mwire-gateway/magnet-bank/src/config.rs | 2++
Awire-gateway/magnet-bank/src/keys.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwire-gateway/magnet-bank/src/magnet.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mwire-gateway/magnet-bank/src/magnet/error.rs | 6+++---
Mwire-gateway/magnet-bank/src/magnet/oauth.rs | 3++-
Mwire-gateway/magnet-bank/src/main.rs | 66+++++++++++-------------------------------------------------------
11 files changed, 425 insertions(+), 68 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,4 @@ .env target -dev.conf -\ No newline at end of file +dev.conf +keys.json +\ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock @@ -245,6 +245,12 @@ dependencies = [ ] [[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -547,6 +553,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -688,6 +706,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -718,6 +750,26 @@ dependencies = [ ] [[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -771,6 +823,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -903,6 +965,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -929,6 +992,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] name = "h2" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1506,18 +1580,21 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" name = "magnet-bank" version = "0.1.0" dependencies = [ + "anyhow", "base64", "clap", "form_urlencoded", - "getrandom", "hmac", "jiff", + "p256", "percent-encoding", + "rand_core", "reqwest", "serde", "serde_json", "serde_urlencoded", "sha1", + "spki", "taler-common", "thiserror 2.0.9", "tokio", @@ -1744,6 +1821,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1895,6 +1984,15 @@ dependencies = [ ] [[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2070,6 +2168,16 @@ dependencies = [ ] [[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] name = "ring" version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2225,6 +2333,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/common/taler-common/src/json_file.rs b/common/taler-common/src/json_file.rs @@ -0,0 +1,33 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::path::Path; + +use serde::{de::DeserializeOwned, Serialize}; + +pub fn persist<T: Serialize>(path: impl AsRef<Path>, value: &T) -> std::io::Result<()> { + let path = path.as_ref(); + let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + serde_json::to_writer(&mut tmp, value)?; + tmp.persist(path)?; + Ok(()) +} + +pub fn load<T: DeserializeOwned>(path: impl AsRef<Path>) -> std::io::Result<T> { + let mut file = std::fs::File::open(path)?; + let content = serde_json::from_reader(&mut file)?; + Ok(content) +} diff --git a/common/taler-common/src/lib.rs b/common/taler-common/src/lib.rs @@ -19,4 +19,5 @@ pub mod api_params; pub mod api_wire; pub mod config; pub mod error_code; +pub mod json_file; pub mod types; diff --git a/wire-gateway/magnet-bank/Cargo.toml b/wire-gateway/magnet-bank/Cargo.toml @@ -4,14 +4,17 @@ version = "0.1.0" edition = "2021" [dependencies] -reqwest = "0.12" +rand_core = { version = "*" } +reqwest = { version = "0.12", features = ["json"] } hmac = "0.12" sha1 = "0.10" -getrandom = "0.2" +p256 = { version = "0.13.2", features = ["alloc", "ecdsa"] } +spki = "0.7.3" base64 = "0.22" form_urlencoded = "1.2" percent-encoding = "2.3" serde_urlencoded = "0.7.1" +anyhow = "1.0" taler-common = { path = "../../common/taler-common" } serde_json = { workspace = true, features = ["raw_value"] } jiff = { workspace = true, features = ["serde"] } diff --git a/wire-gateway/magnet-bank/src/config.rs b/wire-gateway/magnet-bank/src/config.rs @@ -22,6 +22,7 @@ use crate::magnet::Token; pub struct MagnetConfig { pub api_url: Url, pub consumer: Token, + pub keys_path: String, } impl MagnetConfig { @@ -33,6 +34,7 @@ impl MagnetConfig { key: sect.str("CONSUMER_KEY").require()?, secret: sect.str("CONSUMER_SECRET").require()?, }, + keys_path: sect.path("KEYS_FILE").require()?, }) } } diff --git a/wire-gateway/magnet-bank/src/keys.rs b/wire-gateway/magnet-bank/src/keys.rs @@ -0,0 +1,119 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::io::{BufRead, ErrorKind}; + +use p256::ecdsa::SigningKey; +use taler_common::{json_file, types::base32::Base32}; +use tracing::info; + +use crate::{ + config::MagnetConfig, + magnet::{AuthClient, Token, TokenAuth}, +}; + +#[derive(Default, Debug, serde::Deserialize, serde::Serialize)] +pub struct MagnetKeys { + access_token: Option<Token>, + signing_key: Option<Base32<32>>, +} + +pub async fn setup(cfg: MagnetConfig) -> Result<(), anyhow::Error> { + let mut keys = match json_file::load(&cfg.keys_path) { + Ok(existing) => existing, + Err(e) => match e.kind() { + ErrorKind::NotFound => MagnetKeys::default(), + _ => Err(e)?, + }, + }; + + let client = AuthClient::new( + reqwest::Client::new(), + cfg.api_url.clone(), + cfg.consumer.clone(), + ); + + info!("Setup OAuth access token"); + if keys.access_token.is_none() { + let token_request = client.token_request().await?; + + // TODO how to do it in a generic way ? + // TODO Ask MagnetBank if they could support out-of-band configuration + println!( + "Login at {}?oauth_token={}\nEnter the result url>", + client.join("/NetBankOAuth/authtoken.xhtml"), + token_request.key + ); + // TODO better prompting + let prompt = std::io::stdin() + .lock() + .lines() + .next() + .expect("Missing auth URL line") + .expect("Reading auth URL prompt"); + let auth_url = reqwest::Url::parse(&prompt).expect("Auth URL malformed"); + let token_auth: TokenAuth = + serde_urlencoded::from_str(auth_url.query().unwrap_or_default()) + .expect("Auth URL malformed"); + assert_eq!(token_request.key, token_auth.oauth_token); + + let access_token = client.token_access(&token_request, &token_auth).await?; + keys.access_token = Some(access_token); + json_file::persist(&cfg.keys_path, &keys)?; + } + + let client = client.upgrade(keys.access_token.clone().unwrap()); + + info!("Setup Strong Customer Authentication"); + // TODO find a proper way to check if SCA is required without trigerring SCA.GLOBAL_FEATURE_NOT_ENABLED + let perform_sca = false; + if perform_sca { + let request = client.request_sms_code().await?; + println!( + "A SCA code have been sent through {} to {}\nEnter the code>", + request.channel, + request.sent_to.join(", ") + ); + // TODO better prompting + let prompt = std::io::stdin() + .lock() + .lines() + .next() + .expect("Missing SCA code line") + .expect("Reading SCA code prompt"); + client.perform_sca(&prompt).await?; + } + + info!("Setup public key"); + // TODO find a proper way to check if a public key have been setup + let perform_public_key = false; + if perform_public_key { + // TODO use the beter from/to_array API in the next version of the crypto lib + let signing_key = match keys.signing_key { + Some(bytes) => SigningKey::from_slice(bytes.as_ref())?, + None => { + let rand = SigningKey::random(&mut rand_core::OsRng); + let array: [u8; 32] = rand.to_bytes().as_slice().try_into().unwrap(); + keys.signing_key = Some(Base32::from(array)); + json_file::persist(&cfg.keys_path, &keys)?; + rand + } + }; + client.setup_public_key(&signing_key).await?; + } + + Ok(()) +} diff --git a/wire-gateway/magnet-bank/src/magnet.rs b/wire-gateway/magnet-bank/src/magnet.rs @@ -14,14 +14,18 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use base64::{prelude::BASE64_STANDARD, Engine}; use error::ApiResult; +use p256::{ecdsa::SigningKey, PublicKey}; +use serde_json::{json, Value}; +use spki::EncodePublicKey; use crate::magnet::{error::MagnetBuilder, oauth::OAuthBuilder}; pub mod error; mod oauth; -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub struct Token { #[serde(rename = "oauth_token")] pub key: String, @@ -35,13 +39,62 @@ pub struct TokenAuth { pub oauth_verifier: String, } +#[derive(serde::Deserialize, Debug)] +pub struct Consumer { + #[serde(rename = "consumerKey")] + pub key: String, + #[serde(rename = "megnevezes")] + pub name: String, + #[serde(rename = "callbackUri")] + pub callback_uri: String, + #[serde(rename = "elettartam")] + pub lifetime: u64, +} + +#[derive(serde::Deserialize, Debug)] +pub struct TokenInfo { + #[serde(rename = "keszult")] + pub created: jiff::Timestamp, + #[serde(rename = "lejarat")] + pub expiration: jiff::Timestamp, + #[serde(rename = "kliensinfo")] + pub client_info: Option<String>, + pub consumer: Consumer, + #[serde(rename = "hitelesitett")] + pub authenticated: bool, +} + +#[derive(serde::Deserialize, Debug)] +pub struct SmsCodeSubmission { + #[serde(rename = "csatorna")] + pub channel: String, + #[serde(rename = "hovaMentKi")] + pub sent_to: Vec<String>, +} + +#[derive(serde::Deserialize, Debug)] +pub struct ScaResult { + #[serde(rename = "csatorna")] + pub channel: String, + #[serde(rename = "hovaMentKi")] + pub sent_to: Vec<String>, +} + pub struct AuthClient { - pub client: reqwest::Client, - pub api_url: reqwest::Url, - pub consumer: Token, + client: reqwest::Client, + api_url: reqwest::Url, + consumer: Token, } impl AuthClient { + pub fn new(client: reqwest::Client, api_url: reqwest::Url, consumer: Token) -> Self { + Self { + client, + api_url, + consumer, + } + } + pub fn join(&self, path: &str) -> reqwest::Url { self.api_url.join(path).unwrap() } @@ -72,4 +125,70 @@ impl AuthClient { .magnet_call_encoded() .await } + + pub fn upgrade(self, access: Token) -> ApiClient { + ApiClient { + client: self.client, + api_url: self.api_url, + consumer: self.consumer, + access, + } + } +} + +pub struct ApiClient { + client: reqwest::Client, + api_url: reqwest::Url, + consumer: Token, + access: Token, +} + +impl ApiClient { + pub fn join(&self, path: &str) -> reqwest::Url { + self.api_url.join(path).unwrap() + } + + pub async fn token_info(&self) -> ApiResult<TokenInfo> { + self.client + .get(self.join("/RESTApi/resources/v2/token")) + .oauth(&self.consumer, Some(&self.access), None) + .await + .magnet_json() + .await + } + + pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> { + self.client + .get(self.join("/RESTApi/resources/v2/kodszo/sms/token")) + .oauth(&self.consumer, Some(&self.access), None) + .await + .magnet_json() + .await + } + + pub async fn perform_sca(&self, code: &str) -> ApiResult<()> { + self.client + .put(self.join("/RESTApi/resources/v2/token/SCA")) + .json(&json!({ + "kodszo": code + })) + .oauth(&self.consumer, Some(&self.access), None) + .await + .magnet_json() + .await + } + + pub async fn setup_public_key(&self, key: &SigningKey) -> ApiResult<Value> { + let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar()); + let der = public_key.to_public_key_der().unwrap().to_vec(); + self.client + .post(self.join("/RESTApi/resources/v2/token/public-key")) + .json(&json!({ + "keyData": BASE64_STANDARD.encode(der) + })) + .oauth(&self.consumer, Some(&self.access), None) + .await + .magnet_json() + .await + } } diff --git a/wire-gateway/magnet-bank/src/magnet/error.rs b/wire-gateway/magnet-bank/src/magnet/error.rs @@ -42,15 +42,15 @@ pub struct MagnetError { pub enum ApiError { #[error("transport: {0}")] Transport(#[from] reqwest::Error), - #[error("magnet: {0}")] + #[error("magnet {0}")] Magnet(#[from] MagnetError), #[error("JSON body: {0}")] Json(#[from] serde_json::Error), #[error("form body: {0}")] Form(#[from] serde_urlencoded::de::Error), - #[error("status: {0}")] + #[error("status {0}")] Status(StatusCode), - #[error("status: {0} '{1}'")] + #[error("status {0} '{1}'")] StatusCause(StatusCode, String), } diff --git a/wire-gateway/magnet-bank/src/magnet/oauth.rs b/wire-gateway/magnet-bank/src/magnet/oauth.rs @@ -21,6 +21,7 @@ use hmac::{Hmac, Mac}; use percent_encoding::NON_ALPHANUMERIC; use reqwest::header::HeaderValue; use sha1::Sha1; +use rand_core::RngCore; use super::Token; @@ -30,7 +31,7 @@ type HmacSha1 = Hmac<Sha1>; fn oauth_nonce() -> String { // Generate 8 secure random bytes let mut buf = [0u8; 8]; - getrandom::getrandom(&mut buf).expect("OS rand 8 bytes"); + rand_core::OsRng.fill_bytes(&mut buf); // Encode as base64 string BASE64_STANDARD.encode(buf) } diff --git a/wire-gateway/magnet-bank/src/main.rs b/wire-gateway/magnet-bank/src/main.rs @@ -14,19 +14,16 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::{fmt::Display, future::Future, io::BufRead, path::PathBuf}; +use std::{future::Future, path::PathBuf}; use clap::Parser; use config::MagnetConfig; -use magnet::{error::ApiError, AuthClient, TokenAuth}; -use taler_common::config::{ - parser::{ConfigSource, ParserErr}, - Config, ValueError, -}; +use taler_common::config::{parser::ConfigSource, Config}; use tracing::{error, Level}; -use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter}; +use tracing_subscriber::{util::SubscriberInitExt as _, FmtSubscriber}; mod config; +mod keys; mod magnet; #[derive(clap::Parser, Debug)] @@ -52,14 +49,13 @@ enum Command { Setup, } -fn setup<E: Display>(level: Option<tracing::Level>, app: impl Future<Output = Result<(), E>>) { +fn setup(level: Option<tracing::Level>, app: impl Future<Output = Result<(), anyhow::Error>>) { // Setup logger let level = level.unwrap_or(Level::INFO); - let filter: EnvFilter = format!("magnet-bank={level}").into(); - tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) - .with(filter) - .init(); + FmtSubscriber::builder() + .with_max_level(level) + .finish() + .set_default(); // Setup async runtime let runtime = tokio::runtime::Builder::new_multi_thread() @@ -75,53 +71,13 @@ fn setup<E: Display>(level: Option<tracing::Level>, app: impl Future<Output = Re } } -#[derive(Debug, thiserror::Error)] -enum MagnetError { - #[error("{0}")] - CfgParsing(#[from] ParserErr), - #[error("{0}")] - Config(#[from] ValueError), - #[error("{0}")] - Api(#[from] ApiError), -} - -async fn app(args: Args) -> Result<(), MagnetError> { +async fn app(args: Args) -> Result<(), anyhow::Error> { let source = ConfigSource::new("magnet-bank", "magnet-bank", "magnet-bank"); let cfg = Config::from_file(source, args.config)?; let cfg = MagnetConfig::parse(&cfg)?; match args.cmd { - Command::Setup => { - println!("Setup"); - let client = AuthClient { - client: reqwest::Client::new(), - api_url: cfg.api_url, - consumer: cfg.consumer, - }; - let token_request = client.token_request().await?; - - // TODO how to do it in a generic way ? - // TODO Ask MagnetBank if they could support out-of-band configuration - println!( - "Login at {}?oauth_token={}\nEnter the result url>", - client.join("/NetBankOAuth/authtoken.xhtml"), - token_request.key - ); - let prompt = std::io::stdin() - .lock() - .lines() - .next() - .expect("Missing auth URL line") - .expect("Reading auth URL prompt"); - let auth_url = reqwest::Url::parse(&prompt).expect("Auth URL malformed"); - let token_auth: TokenAuth = - serde_urlencoded::from_str(auth_url.query().unwrap_or_default()) - .expect("Auth URL malformed"); - assert_eq!(token_request.key, token_auth.oauth_token); - - let auth_token = client.token_access(&token_request, &token_auth).await?; - dbg!(auth_token); - } + Command::Setup => keys::setup(cfg).await?, } Ok(()) }