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 }