taler-rust

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

setup.rs (8501B)


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