taler-rust

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

setup.rs (8486B)


      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::{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) -> anyhow::Result<Keys> {
     46     // Load JSON file
     47     let file: KeysFile = match json_file::load(&cfg.keys_path) {
     48         Ok(file) => file,
     49         Err(e) => {
     50             return Err(anyhow::anyhow!(
     51                 "Could not read magnet keys at '{}': {}",
     52                 cfg.keys_path,
     53                 e.kind()
     54             ));
     55         }
     56     };
     57 
     58     fn incomplete_err() -> anyhow::Error {
     59         anyhow::anyhow!("Missing magnet keys, run 'taler-magnet-bank setup' first")
     60     }
     61 
     62     // Check full
     63     let access_token = file.access_token.ok_or_else(incomplete_err)?;
     64     let signing_key = file.signing_key.ok_or_else(incomplete_err)?;
     65 
     66     // Load signing key
     67     let signing_key = parse_private_key(&signing_key)?;
     68 
     69     Ok(Keys {
     70         access_token,
     71         signing_key,
     72     })
     73 }
     74 
     75 pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> {
     76     if reset
     77         && let Err(e) = std::fs::remove_file(&cfg.keys_path)
     78         && e.kind() != ErrorKind::NotFound
     79     {
     80         Err(e)?;
     81     }
     82     let mut keys = match json_file::load(&cfg.keys_path) {
     83         Ok(existing) => existing,
     84         Err(e) if e.kind() == ErrorKind::NotFound => KeysFile::default(),
     85         Err(e) => Err(e)?,
     86     };
     87     let client = http_client::client()?;
     88     let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer);
     89 
     90     info!("Setup OAuth access token");
     91     if keys.access_token.is_none() {
     92         let token_request = client.token_request().await?;
     93 
     94         // TODO how to do it in a generic way ?
     95         // TODO Ask MagnetBank if they could support out-of-band configuration
     96         println!(
     97             "Login at {}?oauth_token={}",
     98             client
     99                 .api_url
    100                 .join("/NetBankOAuth/authtoken.xhtml")
    101                 .unwrap(),
    102             token_request.key
    103         );
    104         let auth_url = rpassword::prompt_password("Enter the result URL>")?;
    105         let auth_url = url::Url::parse(&auth_url)?;
    106         let token_auth: TokenAuth =
    107             serde_urlencoded::from_str(auth_url.query().unwrap_or_default())?;
    108         assert_eq!(token_request.key, token_auth.oauth_token);
    109 
    110         let access_token = client.token_access(&token_request, &token_auth).await?;
    111         keys.access_token = Some(access_token);
    112         json_file::persist(&cfg.keys_path, &keys)?;
    113     }
    114 
    115     let client = client.upgrade(keys.access_token.as_ref().unwrap());
    116 
    117     info!("Setup Strong Customer Authentication");
    118     // TODO find a proper way to check if SCA is required without triggering SCA.GLOBAL_FEATURE_NOT_ENABLED
    119     let request = client.request_sms_code().await?;
    120     println!(
    121         "A SCA code have been sent through {} to {}",
    122         request.channel,
    123         request.sent_to.join(", ")
    124     );
    125     let sca_code = rpassword::prompt_password("Enter the code>")?;
    126     if let Err(e) = client.perform_sca(&sca_code).await {
    127         // Ignore error if SCA already performed
    128         if !matches!(e.err, MagnetErr::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT")
    129         {
    130             return Err(e.into());
    131         }
    132     }
    133 
    134     info!("Setup public key");
    135     // TODO find a proper way to check if a public key have been setup
    136     let signing_key = match keys.signing_key {
    137         Some(bytes) => parse_private_key(&bytes)?,
    138         None => {
    139             let rand = EcdsaKeyPair::generate(MAGNET_SIGNATURE)?;
    140             keys.signing_key = Some(Base32::from(encode_private_key(&rand)?));
    141             json_file::persist(&cfg.keys_path, &keys)?;
    142             rand
    143         }
    144     };
    145     if let Err(e) = client.upload_public_key(&signing_key).await {
    146         // Ignore error if public key already uploaded
    147         if !matches!(e.err, MagnetErr::Magnet(MagnetError { ref short_message, .. }) if short_message== "KULCS_MAR_HASZNALATBAN")
    148         {
    149             return Err(e.into());
    150         }
    151     }
    152 
    153     info!("Check account");
    154     let res = client.list_accounts().await?;
    155     let mut ibans = Vec::new();
    156     for partner in res.partners {
    157         for account in partner.bank_accounts {
    158             if *cfg.payto == account.iban {
    159                 if partner.partner.name != cfg.payto.name {
    160                     warn!(
    161                         "Expected name '{}' from config got '{}' from bank",
    162                         cfg.payto.name, partner.partner.name
    163                     );
    164                 }
    165                 return Ok(());
    166             } else {
    167                 ibans.push(account.iban);
    168             }
    169         }
    170     }
    171     Err(anyhow::anyhow!(
    172         "Unknown account {} from config, expected one of the following account {}",
    173         cfg.payto.0,
    174         ibans
    175             .into_iter()
    176             .map(|it| it.to_string())
    177             .collect::<Vec<_>>()
    178             .join(", ")
    179     ))
    180 }
    181 
    182 /** Parse a 32B ECDSA private key */
    183 fn parse_private_key(encoded: &[u8; 32]) -> anyhow::Result<EcdsaKeyPair> {
    184     // Recreate the pkcs8 from the raw private key bytes as aws-lc-rs does not support the raw bytes
    185     let mut pkcs8 = [
    186         // --- PKCS#8 Header ---
    187         0x30, 0x41, // Sequence (65 bytes remaining)
    188         0x02, 0x01, 0x00, // Version v1 (0)
    189         // --- AlgorithmIdentifier (P-256) ---
    190         0x30, 0x13, // Sequence (19 bytes)
    191         0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID: ecPublicKey
    192         0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, // OID: prime256v1
    193         // --- PrivateKey (Wrapped Octet String) ---
    194         0x04, 0x27, // Octet String (39 bytes)
    195         // --- Inside: The ECPrivateKey Structure (RFC 5915) ---
    196         0x30, 0x25, // Sequence (37 bytes)
    197         0x02, 0x01, 0x01, // Version 1
    198         0x04, 0x20, // Octet String (32 bytes)
    199         // [32 bytes of key data will go here]
    200         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,
    201         0,
    202     ];
    203     pkcs8[35..67].copy_from_slice(encoded);
    204 
    205     let key = EcdsaKeyPair::from_pkcs8(MAGNET_SIGNATURE, &pkcs8)?;
    206     Ok(key)
    207 }
    208 
    209 /** Encode a ECDSA private key into 32B */
    210 fn encode_private_key(key: &EcdsaKeyPair) -> anyhow::Result<[u8; 32]> {
    211     let array: [u8; 32] = key.private_key().as_be_bytes()?.as_ref().try_into()?;
    212     Ok(array)
    213 }
    214 
    215 #[cfg(test)]
    216 mod test {
    217     use taler_common::json_file;
    218 
    219     use crate::setup::{KeysFile, encode_private_key, parse_private_key};
    220 
    221     #[test]
    222     fn keys_files() {
    223         // Load JSON file
    224         let content: KeysFile = json_file::load("./fixtures/setup.json").unwrap();
    225         // Check full
    226         assert!(content.access_token.is_some());
    227         let key = content.signing_key.clone().unwrap();
    228 
    229         // Load signing key
    230         let secret_key = parse_private_key(&key).unwrap();
    231 
    232         // Check encoded round trip
    233         assert_eq!(encode_private_key(&secret_key).unwrap(), *key);
    234 
    235         // Check JSON round trip
    236         let tmp_path = "/tmp/keys.json";
    237         if std::fs::exists(tmp_path).unwrap() {
    238             std::fs::remove_file(tmp_path).unwrap();
    239         }
    240         json_file::persist(tmp_path, &content).unwrap();
    241         assert_eq!(json_file::load::<KeysFile>(tmp_path).unwrap(), content);
    242     }
    243 }