From fcafcff69e616301231e3520af8b2b7ad097ba90 Mon Sep 17 00:00:00 2001 From: Sosthene Date: Mon, 13 May 2024 16:18:17 +0200 Subject: [PATCH] Return transaction to confirm identity --- crates/sp_client/src/api.rs | 181 ++++++++++++++++++++++++++---------- crates/sp_client/src/lib.rs | 34 ++++++- src/services.ts | 43 ++++++++- src/websockets.ts | 27 +++--- 4 files changed, 212 insertions(+), 73 deletions(-) diff --git a/crates/sp_client/src/api.rs b/crates/sp_client/src/api.rs index eb6b1aa..0eb433c 100644 --- a/crates/sp_client/src/api.rs +++ b/crates/sp_client/src/api.rs @@ -5,15 +5,16 @@ use std::str::FromStr; use std::string::FromUtf8Error; use std::sync::{Mutex, OnceLock, PoisonError}; -use log::debug; +use log::{debug, warn}; use rand::{Fill, Rng}; use anyhow::Error as AnyhowError; use sdk_common::crypto::{ AeadCore, Aes256Decryption, Aes256Encryption, Aes256Gcm, AnkSharedSecret, KeyInit, Purpose, }; -use serde_json::Error as SerdeJsonError; +use serde_json::{Error as SerdeJsonError, Value}; use shamir::SecretData; +use sp_client::bitcoin::blockdata::fee_rate; use sp_client::bitcoin::consensus::{deserialize, serialize}; use sp_client::bitcoin::hex::{parse, DisplayHex, FromHex, HexToBytesError}; use sp_client::bitcoin::secp256k1::ecdh::shared_secret_point; @@ -27,16 +28,16 @@ use tsify::Tsify; use wasm_bindgen::convert::FromWasmAbi; use wasm_bindgen::prelude::*; -use sdk_common::network::{AnkFlag, AnkNetworkMsg, NewTxMessage}; +use sdk_common::network::{AnkFlag, AnkNetworkMsg, NewTxMessage, UnknownMessage}; use sdk_common::silentpayments::{ - check_transaction, create_transaction_for_address_with_shared_secret, + check_transaction, create_transaction, create_transaction_for_address_with_shared_secret, }; use sp_client::spclient::{derive_keys_from_seed, OutputList, OwnedOutput, SpClient}; use sp_client::spclient::{SpWallet, SpendKey}; use crate::user::{lock_connected_user, User, UserWallets, CONNECTED_USER}; -use crate::{images, lock_scanned_transactions, Txid2Secrets}; +use crate::{images, lock_scanned_transactions, lock_secrets, lock_watched, Txid2Secrets}; use crate::process::Process; @@ -330,16 +331,80 @@ pub fn login_user( Ok(res) } +pub fn scan_for_confirmation_transaction(tx_hex: String) -> anyhow::Result { + let tx = deserialize::(&Vec::from_hex(&tx_hex)?)?; + + for i in tx.input.iter() { + if let Some(waiting) = lock_watched()?.remove(&i.previous_output) { + match lock_secrets()?.get_mut(&waiting) { + None => return Err(anyhow::Error::msg("No secret match for an error we're waiting for")), + Some(secret) => { + // for now we only handle the case of one secret for one transaction + let res = secret.get_mut(0).unwrap(); + res.1.trusted = true; + return Ok(res.0.clone()); + } + } + } + } + + Err(anyhow::Error::msg("Not spending a watched output")) +} + +fn handle_recover_transaction(tx: Transaction, sp_wallet: &mut SpWallet, tweak_data: PublicKey, fee_rate: u32) -> anyhow::Result> { + // does this transaction spent a txid and output we're waiting confirmation for? + let scan_sk = sp_wallet.get_client().get_scan_key(); + let txid = tx.txid(); + for input in tx.input { + let prevout = input.previous_output; + match lock_secrets()?.get_mut(&prevout.txid) { + None => { + continue; + } + Some(secret) => { + // We found an input spending a notification transaction we sent + if let Some(res) = secret.get_mut(prevout.vout as usize) { + // This is a challenge from a previous message we sent + // we toggle the trusted value + if !res.1.trusted { + res.1.trusted = true; + } else { + return Err(anyhow::Error::msg("Received a confirmation for a transaction we already confirmed")); + } + // We spend the output back to the receiver + let sp_address = SilentPaymentAddress::try_from(res.0.as_str()).expect("Invalid silent payment address"); + let response_tx = create_transaction(sp_address, sp_wallet, Amount::from_sat(fee_rate.into()))?; + return Ok(Some(response_tx)); + } else { + return Err(anyhow::Error::msg("Received a confirmation from an umapped output")); + } + } + } + } + // If we exhausted all inputs without finding one of our transaction, it means it's a notification + let shared_point = + shared_secret_point(&tweak_data, &scan_sk); + lock_secrets()?.insert( + txid, + vec![("".to_owned(), AnkSharedSecret::new(shared_point, false))], + ); + Ok(None) +} + #[wasm_bindgen] pub fn check_transaction_for_silent_payments( tx_hex: String, blockheight: u32, tweak_data_hex: String, + fee_rate: u32, ) -> ApiResult { let tx = deserialize::(&Vec::from_hex(&tx_hex)?)?; - // check that we don't already have scanned the tx - if lock_scanned_transactions()?.contains_key(&tx.txid()) { - return Err(ApiError { message: "Transaction already scanned".to_owned()}); + + // check that we don't already have scanned the tx, and insert it if we don't + if !lock_scanned_transactions()?.insert(tx.txid()) { + return Err(ApiError { + message: "Transaction already scanned".to_owned(), + }); } let tweak_data = PublicKey::from_str(&tweak_data_hex)?; @@ -347,30 +412,28 @@ pub fn check_transaction_for_silent_payments( let mut connected_user = lock_connected_user()?; if let Ok(recover) = connected_user.try_get_mut_recover() { if let Ok(txid) = check_transaction(&tx, recover, blockheight, tweak_data) { - let shared_point = shared_secret_point(&tweak_data, &recover.get_client().get_scan_key()); - lock_scanned_transactions()?.insert(tx.txid(), vec![("".to_owned(), AnkSharedSecret::new(shared_point, false))]); + if let Err(e) = scan_for_confirmation_transaction(tx_hex) { + log::error!("{}", e); + handle_recover_transaction(tx, recover, tweak_data, fee_rate)?; + } return Ok(txid); } } if let Ok(main) = connected_user.try_get_mut_main() { if let Ok(txid) = check_transaction(&tx, main, blockheight, tweak_data) { - let shared_point = shared_secret_point(&tweak_data, &main.get_client().get_scan_key()); - lock_scanned_transactions()?.insert(tx.txid(), vec![("".to_owned(), AnkSharedSecret::new(shared_point, false))]); + // TODO return Ok(txid); } } if let Ok(revoke) = connected_user.try_get_mut_revoke() { if let Ok(txid) = check_transaction(&tx, revoke, blockheight, tweak_data) { - let shared_point = shared_secret_point(&tweak_data, &revoke.get_client().get_scan_key()); - lock_scanned_transactions()?.insert(tx.txid(), vec![("".to_owned(), AnkSharedSecret::new(shared_point, false))]); + // TODO return Ok(txid); } } - // we still want to insert an empty entry in our cache to make sure we don't scan the transaction again - lock_scanned_transactions()?.insert(tx.txid(), vec![("".to_owned(), AnkSharedSecret::default())]); Err(ApiError { message: "No output found".to_owned(), }) @@ -385,7 +448,7 @@ pub struct parseNetworkMsgReturn { } #[wasm_bindgen] -pub fn parse_network_msg(raw: String) -> ApiResult { +pub fn parse_network_msg(raw: String, fee_rate: u32) -> ApiResult { if let Ok(ank_msg) = serde_json::from_str::(&raw) { match ank_msg.flag { AnkFlag::NewTx => { @@ -399,6 +462,7 @@ pub fn parse_network_msg(raw: String) -> ApiResult { tx_message.transaction, 0, tx_message.tweak_data.unwrap(), + fee_rate, )?; return Ok(parseNetworkMsgReturn { topic: AnkFlag::NewTx.as_str().to_owned(), @@ -414,44 +478,60 @@ pub fn parse_network_msg(raw: String) -> ApiResult { } AnkFlag::Unknown => { // try to decrypt the cipher with all available keys - let mut plaintext: String = "".to_owned(); - for (txid, secret_vec) in lock_scanned_transactions()?.iter() { - for (shared_with, ank_secret) in secret_vec.iter() { - if *ank_secret == AnkSharedSecret::default() { - continue; - } + for (txid, secret_vec) in lock_secrets()?.iter_mut() { + // Actually we probably will ever have only one secret in the case we're receiver + for (shared_with, ank_secret) in secret_vec.iter_mut() { + // if we already have shared_with, that means we already used that key for another message + if !shared_with.is_empty() { continue } let shared_secret = ank_secret.to_byte_array(); - if let Ok(msg_decrypt) = Aes256Decryption::new( + debug!("{} {}", shared_with, shared_secret.to_lower_hex_string()); + let msg_decrypt = Aes256Decryption::new( Purpose::Arbitrary, Vec::from_hex(&ank_msg.content.trim_matches('\"'))?, shared_secret, - ) { - match msg_decrypt.decrypt_with_key() { - Ok(plain) => { - plaintext = String::from_utf8(plain)?; - break; - }, - Err(e) => { - debug!("{}", e); - debug!("Failed to decrypt message {} with key {}", ank_msg.content, shared_secret.to_lower_hex_string()); + )?; + match msg_decrypt.decrypt_with_key() { + Ok(plaintext) => { + let unknown_msg = serde_json::from_slice::(&plaintext); + if unknown_msg.is_err() { + // The message we were sent is invalid, drop everything + // for now let's just fill the shared_with with garbage + *shared_with = "a".to_owned(); + return Err(ApiError { message: "Invalid msg".to_owned() }) } + let sender: Result = unknown_msg.unwrap().sender.try_into(); + if sender.is_err() { + // The sender is invalid address + *shared_with = "a".to_owned(); + return Err(ApiError { message: "Invalid sp address".to_owned() }) + } + + // we update our list with the sender address + *shared_with = sender.unwrap().into(); + + // We return the whole message + // ts is responsible for sending the confirmation message + return Ok(parseNetworkMsgReturn { + topic: AnkFlag::Unknown.as_str().to_owned(), + message: String::from_utf8(plaintext)?, + }); + } + Err(e) => { + debug!("{}", e); + debug!( + "Failed to decrypt message {} with key {}", + ank_msg.content, + shared_secret.to_lower_hex_string() + ); } } } } - if plaintext.is_empty() { - // keep the message in cache, just in case - // return an error - return Err(ApiError { - message: "No key found".to_owned(), - }); - } else { - // return the plain text - return Ok(parseNetworkMsgReturn { - topic: AnkFlag::Unknown.as_str().to_owned(), - message: plaintext, - }); - } + // keep the message in cache, just in case? + // return an error + return Err(ApiError { + message: "No key found".to_owned(), + }); } _ => unimplemented!(), } @@ -523,7 +603,7 @@ pub struct createNotificationTransactionReturn { #[wasm_bindgen] pub fn create_notification_transaction( recipient: String, - message: String, + message: Option, fee_rate: u32, ) -> ApiResult { let sp_address: SilentPaymentAddress = recipient.try_into()?; @@ -544,13 +624,16 @@ pub fn create_notification_transaction( Amount::from_sat(fee_rate.into()), )?; - debug!("Created transaction with secret {}", shared_secret.to_byte_array().to_lower_hex_string()); + debug!( + "Created transaction with secret {}", + shared_secret.to_byte_array().to_lower_hex_string() + ); let mut address2secret: Vec<(String, AnkSharedSecret)> = vec![]; address2secret.push((sp_address.into(), shared_secret)); // update our cache - lock_scanned_transactions()?.insert(transaction.txid(), address2secret.clone()); + lock_secrets()?.insert(transaction.txid(), address2secret.clone()); Ok(createNotificationTransactionReturn { txid: transaction.txid().to_string(), diff --git a/crates/sp_client/src/lib.rs b/crates/sp_client/src/lib.rs index 8d7155c..4802a9f 100644 --- a/crates/sp_client/src/lib.rs +++ b/crates/sp_client/src/lib.rs @@ -2,9 +2,9 @@ use anyhow::Error; use sdk_common::crypto::AnkSharedSecret; use serde::{Deserialize, Serialize}; -use sp_client::bitcoin::Txid; +use sp_client::bitcoin::{OutPoint, Txid}; use sp_client::silentpayments::sending::SilentPaymentAddress; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::{Mutex, MutexGuard, OnceLock}; use tsify::Tsify; @@ -16,16 +16,40 @@ mod peers; mod process; mod user; +/// We map txid with one or n secrets +/// Each secret match one sp address +/// When we first detect a transaction, we can't tell who's the sender, so we like sp address empty +/// When we receive the corresponding message, we get a sp address declaration, we complete here +/// Then when we send the confirmation transaction and got the response we can flip the secret to trusted pub type Txid2Secrets = HashMap>; -pub static TRANSACTIONCACHE: OnceLock> = OnceLock::new(); +pub static SECRETCACHE: OnceLock> = OnceLock::new(); -pub fn lock_scanned_transactions() -> Result, Error> { - TRANSACTIONCACHE +pub fn lock_secrets() -> Result, Error> { + SECRETCACHE .get_or_init(|| Mutex::new(Txid2Secrets::new())) .lock_anyhow() } +/// this is to keep track of transaction we already analysed without finding notification +/// This is not critical and there's no need to keep that in persistent storage, as most transactions would only show up twice +/// Worst case is we will scan again transactions when they got into a block +pub static TRANSACTIONCACHE: OnceLock>> = OnceLock::new(); + +pub fn lock_scanned_transactions() -> Result>, Error> { + TRANSACTIONCACHE + .get_or_init(|| Mutex::new(HashSet::new())) + .lock_anyhow() +} + +pub static WATCHEDUTXO: OnceLock>> = OnceLock::new(); + +pub fn lock_watched() -> Result>, Error> { + WATCHEDUTXO + .get_or_init(|| Mutex::new(HashMap::new())) + .lock_anyhow() +} + pub(crate) trait MutexExt { fn lock_anyhow(&self) -> Result, Error>; } diff --git a/src/services.ts b/src/services.ts index c328cd3..4d95df8 100644 --- a/src/services.ts +++ b/src/services.ts @@ -105,7 +105,7 @@ class Services { const recipientSpAddress = spAddressElement.value; const message = messageElement.value; - const msg_payload = JSON.stringify({sender: this.sp_address, payload: message}); + const msg_payload = JSON.stringify({sender: this.sp_address, message: message}); let notificationInfo = await services.notify_address_for_message(recipientSpAddress, msg_payload); if (notificationInfo) { @@ -313,14 +313,13 @@ class Services { services.attachSubmitListener("form4nk", services.updateAnId); } - public async parseNetworkMessage(raw: string): Promise { + public async parseNetworkMessage(raw: string, feeRate: number): Promise { const services = await Services.getInstance(); try { - const msg: parseNetworkMsgReturn = services.sdkClient.parse_network_msg(raw); + const msg: parseNetworkMsgReturn = services.sdkClient.parse_network_msg(raw, feeRate); return msg; } catch (error) { - console.error(error); - return null; + throw error; } } @@ -802,6 +801,40 @@ class Services { } } + public async confirm_sender_address(sp_address: string): Promise { + const services = await Services.getInstance(); + const connection = await services.pickWebsocketConnectionRandom(); + if (!connection) { + throw new Error("No connection to relay"); + } + let user: User; + try { + let possibleUser = await services.getUserInfo(); + if (!possibleUser) { + throw new Error("No user loaded, please first create a new user or login"); + } else { + user = possibleUser; + } + } catch (error) { + throw error; + } + + let notificationInfo: createNotificationTransactionReturn; + try { + const feeRate = 1; + notificationInfo = services.sdkClient.create_notification_transaction(sp_address, undefined, feeRate); + } catch (error) { + throw new Error(`Failed to create confirmation transaction: ${error}`); + } + const flag: AnkFlag = "NewTx"; + const newTxMsg: NewTxMessage = { + 'transaction': notificationInfo.transaction, + 'tweak_data': null + } + connection.sendMessage(flag, JSON.stringify(newTxMsg)); + return; + } + public async notify_address_for_message(sp_address: string, message: string): Promise { const services = await Services.getInstance(); const connection = await services.pickWebsocketConnectionRandom(); diff --git a/src/websockets.ts b/src/websockets.ts index 3342c31..0234f56 100644 --- a/src/websockets.ts +++ b/src/websockets.ts @@ -25,28 +25,27 @@ class WebSocketClient { (async () => { if (typeof(msgData) === 'string') { - console.log("Received text message: "+msgData); - let res = await services.parseNetworkMessage(msgData); - if (res) { + // console.log("Received text message: "+msgData); + try { + const feeRate = 1; + let res = await services.parseNetworkMessage(msgData, feeRate); if (res.topic === 'new_tx') { // we received a tx window.alert(`New tx\n${res.message}`); await services.updateOwnedOutputsForUser(); } else if (res.topic === 'unknown') { - // Do we have a json with a sender? - try { - let parsed = JSON.parse(res.message); - if (parsed.sender !== undefined) { - console.info(`Message sent by ${parsed.sender}`); - } - window.alert(`new message: ${parsed.payload}`); - } catch (_) { - window.alert(`new message: ${res.message}`); - } + let parsed = JSON.parse(res.message); + let message = parsed['message']; + let sender = parsed['sender']; + window.alert(`new message: ${message}\nAsking sender ${sender} to confirm identity...`); + console.debug(`sending confirm message to ${sender}`); + await services.confirm_sender_address(sender); } + } catch (error) { + console.error('Received an invalid message:', error); } } else { - console.error("Received an invalid message"); + console.error('Received a non-string message'); } })(); });