taler-rust

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

setup.rs (8464B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025, 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::io::ErrorKind;
     18 
     19 use aws_lc_rs::{encoding::AsBigEndian, signature::EcdsaKeyPair};
     20 use taler_common::{config::CfgErr, config_bail, json_file, types::base32::Base32};
     21 use tracing::{info, warn};
     22 
     23 use crate::{
     24     config::WorkerCfg,
     25     constants::MAGNET_SIGNATURE,
     26     magnet_api::{
     27         api::{MagnetErr, MagnetError},
     28         client::AuthClient,
     29         oauth::{Token, TokenAuth},
     30     },
     31 };
     32 
     33 #[derive(Default, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
     34 struct KeysFile {
     35     access_token: Option<Token>,
     36     signing_key: Option<Base32<32>>,
     37 }
     38 
     39 #[derive(Debug)]
     40 pub struct Keys {
     41     pub access_token: Token,
     42     pub signing_key: EcdsaKeyPair,
     43 }
     44 
     45 pub fn load(cfg: &WorkerCfg) -> Result<Keys, CfgErr> {
     46     // Load JSON file
     47     let file: KeysFile = match json_file::load(&cfg.keys_path) {
     48         Ok(file) => file,
     49         Err(e) => config_bail!(
     50             "could not read magnet keys at '{}': {}",
     51             cfg.keys_path,
     52             e.kind()
     53         ),
     54     };
     55 
     56     fn incomplete_err() -> CfgErr {
     57         CfgErr::custom("missing magnet keys, run 'taler-magnet-bank setup' first")
     58     }
     59 
     60     // Check full
     61     let access_token = file.access_token.ok_or_else(incomplete_err)?;
     62     let signing_key = file.signing_key.ok_or_else(incomplete_err)?;
     63 
     64     // Load signing key
     65     let signing_key = parse_private_key(&signing_key).map_err(CfgErr::custom)?;
     66 
     67     Ok(Keys {
     68         access_token,
     69         signing_key,
     70     })
     71 }
     72 
     73 pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> {
     74     if reset
     75         && let Err(e) = std::fs::remove_file(&cfg.keys_path)
     76         && e.kind() != ErrorKind::NotFound
     77     {
     78         Err(e)?;
     79     }
     80     let mut keys = match json_file::load(&cfg.keys_path) {
     81         Ok(existing) => existing,
     82         Err(e) if e.kind() == ErrorKind::NotFound => KeysFile::default(),
     83         Err(e) => Err(e)?,
     84     };
     85     let client = http_client::client();
     86     let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer);
     87 
     88     info!("Setup OAuth access token");
     89     if keys.access_token.is_none() {
     90         let token_request = client.token_request().await?;
     91 
     92         // TODO how to do it in a generic way ?
     93         // TODO Ask MagnetBank if they could support out-of-band configuration
     94         println!(
     95             "Login at {}?oauth_token={}",
     96             client
     97                 .api_url
     98                 .join("/NetBankOAuth/authtoken.xhtml")
     99                 .unwrap(),
    100             token_request.key
    101         );
    102         let auth_url = rpassword::prompt_password("Enter the result URL>")?;
    103         let auth_url = url::Url::parse(&auth_url)?;
    104         let token_auth: TokenAuth =
    105             serde_urlencoded::from_str(auth_url.query().unwrap_or_default())?;
    106         assert_eq!(token_request.key, token_auth.oauth_token);
    107 
    108         let access_token = client.token_access(&token_request, &token_auth).await?;
    109         keys.access_token = Some(access_token);
    110         json_file::persist(&cfg.keys_path, &keys)?;
    111     }
    112 
    113     let client = client.upgrade(keys.access_token.as_ref().unwrap());
    114 
    115     info!("Setup Strong Customer Authentication");
    116     // TODO find a proper way to check if SCA is required without triggering SCA.GLOBAL_FEATURE_NOT_ENABLED
    117     let request = client.request_sms_code().await?;
    118     println!(
    119         "A SCA code have been sent through {} to {}",
    120         request.channel,
    121         request.sent_to.join(", ")
    122     );
    123     let sca_code = rpassword::prompt_password("Enter the code>")?;
    124     if let Err(e) = client.perform_sca(&sca_code).await {
    125         // Ignore error if SCA already performed
    126         if !matches!(&*e.err, MagnetErr::Magnet(MagnetError { short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT")
    127         {
    128             return Err(e.into());
    129         }
    130     }
    131 
    132     info!("Setup public key");
    133     // TODO find a proper way to check if a public key have been setup
    134     let signing_key = match keys.signing_key {
    135         Some(bytes) => parse_private_key(&bytes)?,
    136         None => {
    137             let rand = EcdsaKeyPair::generate(MAGNET_SIGNATURE)?;
    138             keys.signing_key = Some(Base32::from(encode_private_key(&rand)?));
    139             json_file::persist(&cfg.keys_path, &keys)?;
    140             rand
    141         }
    142     };
    143     if let Err(e) = client.upload_public_key(&signing_key).await {
    144         // Ignore error if public key already uploaded
    145         if !matches!(&*e.err, MagnetErr::Magnet(MagnetError { short_message, .. }) if short_message == "KULCS_MAR_HASZNALATBAN")
    146         {
    147             return Err(e.into());
    148         }
    149     }
    150 
    151     info!("Check account");
    152     let res = client.list_accounts().await?;
    153     let mut ibans = Vec::new();
    154     for partner in res.partners {
    155         for account in partner.bank_accounts {
    156             if *cfg.payto == account.iban {
    157                 if partner.partner.name != cfg.payto.name {
    158                     warn!(
    159                         "Expected name '{}' from config got '{}' from bank",
    160                         cfg.payto.name, partner.partner.name
    161                     );
    162                 }
    163                 return Ok(());
    164             } else {
    165                 ibans.push(account.iban);
    166             }
    167         }
    168     }
    169     Err(anyhow::anyhow!(
    170         "Unknown account {} from config, expected one of the following account {}",
    171         cfg.payto.0,
    172         ibans
    173             .into_iter()
    174             .map(|it| it.to_string())
    175             .collect::<Vec<_>>()
    176             .join(", ")
    177     ))
    178 }
    179 
    180 /** Parse a 32B ECDSA private key */
    181 fn parse_private_key(encoded: &[u8; 32]) -> anyhow::Result<EcdsaKeyPair> {
    182     // Recreate the pkcs8 from the raw private key bytes as aws-lc-rs does not support the raw bytes
    183     let mut pkcs8 = [
    184         // --- PKCS#8 Header ---
    185         0x30, 0x41, // Sequence (65 bytes remaining)
    186         0x02, 0x01, 0x00, // Version v1 (0)
    187         // --- AlgorithmIdentifier (P-256) ---
    188         0x30, 0x13, // Sequence (19 bytes)
    189         0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID: ecPublicKey
    190         0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, // OID: prime256v1
    191         // --- PrivateKey (Wrapped Octet String) ---
    192         0x04, 0x27, // Octet String (39 bytes)
    193         // --- Inside: The ECPrivateKey Structure (RFC 5915) ---
    194         0x30, 0x25, // Sequence (37 bytes)
    195         0x02, 0x01, 0x01, // Version 1
    196         0x04, 0x20, // Octet String (32 bytes)
    197         // [32 bytes of key data will go here]
    198         0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    199         0,
    200     ];
    201     pkcs8[35..67].copy_from_slice(encoded);
    202 
    203     let key = EcdsaKeyPair::from_pkcs8(MAGNET_SIGNATURE, &pkcs8)?;
    204     Ok(key)
    205 }
    206 
    207 /** Encode a ECDSA private key into 32B */
    208 fn encode_private_key(key: &EcdsaKeyPair) -> anyhow::Result<[u8; 32]> {
    209     let array: [u8; 32] = key.private_key().as_be_bytes()?.as_ref().try_into()?;
    210     Ok(array)
    211 }
    212 
    213 #[cfg(test)]
    214 mod test {
    215     use taler_common::json_file;
    216 
    217     use crate::setup::{KeysFile, encode_private_key, parse_private_key};
    218 
    219     #[test]
    220     fn keys_files() {
    221         // Load JSON file
    222         let content: KeysFile = json_file::load("./fixtures/setup.json").unwrap();
    223         // Check full
    224         assert!(content.access_token.is_some());
    225         let key = content.signing_key.unwrap();
    226 
    227         // Load signing key
    228         let secret_key = parse_private_key(&key).unwrap();
    229 
    230         // Check encoded round trip
    231         assert_eq!(encode_private_key(&secret_key).unwrap(), *key);
    232 
    233         // Check JSON round trip
    234         let tmp_path = "/tmp/keys.json";
    235         if std::fs::exists(tmp_path).unwrap() {
    236             std::fs::remove_file(tmp_path).unwrap();
    237         }
    238         json_file::persist(tmp_path, &content).unwrap();
    239         assert_eq!(json_file::load::<KeysFile>(tmp_path).unwrap(), content);
    240     }
    241 }