From 12811a24455746b59ba484e40f96780ac38d6174 Mon Sep 17 00:00:00 2001 From: Gary Yu Date: Tue, 25 Dec 2018 08:05:24 +0800 Subject: [PATCH] Keybase notice on receiving grin transaction (#2211) * keybase notification on transaction received * fix: use 'keybase status' to get own username * refactor the code * security enhancement for multiple recipients * change the poll interval from 5s to 1s * log the error message of keybase api --- config/src/comments.rs | 7 +- wallet/src/adapters/keybase.rs | 304 ++++++++++++++++++++++++++------- wallet/src/command.rs | 4 +- wallet/src/types.rs | 3 + 4 files changed, 248 insertions(+), 70 deletions(-) diff --git a/config/src/comments.rs b/config/src/comments.rs index 31eb398ed..8ed346ffe 100644 --- a/config/src/comments.rs +++ b/config/src/comments.rs @@ -404,9 +404,12 @@ fn comments() -> HashMap { .to_string(), ); retval.insert( - "use_switch_commitments".to_string(), + "keybase_notify_ttl".to_string(), " -#Whether to use switch commitments for this wallet +#The exploding lifetime for keybase notification on coins received. +#Unit: Minute. Default value 1440 minutes for one day. +#Refer to https://keybase.io/blog/keybase-exploding-messages for detail. +#To disable this notification, set it as 0. " .to_string(), ); diff --git a/wallet/src/adapters/keybase.rs b/wallet/src/adapters/keybase.rs index aa9353c1c..6282635ad 100644 --- a/wallet/src/adapters/keybase.rs +++ b/wallet/src/adapters/keybase.rs @@ -28,7 +28,8 @@ use std::thread::sleep; use std::time::{Duration, Instant}; const TTL: u16 = 60; // TODO: Pass this as a parameter -const SLEEP_DURATION: Duration = Duration::from_millis(5000); +const LISTEN_SLEEP_DURATION: Duration = Duration::from_millis(5000); +const POLL_SLEEP_DURATION: Duration = Duration::from_millis(1000); // Which topic names to use for communication const SLATE_NEW: &str = "grin_slate_new"; @@ -55,16 +56,63 @@ impl KeybaseWalletCommAdapter { } /// Send a json object to the keybase process. Type `keybase chat api --help` for a list of available methods. -fn api_send(payload: &str) -> Value { +fn api_send(payload: &str) -> Result { let mut proc = Command::new("keybase"); proc.args(&["chat", "api", "-m", &payload]); - let output = proc.output().expect("No output").stdout; - let response: Value = from_str(from_utf8(&output).expect("Bad output")).expect("Bad output"); - response + let output = proc.output().expect("No output"); + if !output.status.success() { + error!( + "keybase api fail: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(ErrorKind::GenericError("keybase api fail".to_owned()))? + } else { + let response: Value = + from_str(from_utf8(&output.stdout).expect("Bad output")).expect("Bad output"); + let err_msg = format!("{}", response["error"]["message"]); + if err_msg.len() > 0 && err_msg != "null" { + error!("api_send got error: {}", err_msg); + } + + Ok(response) + } +} + +/// Get keybase username +fn whoami() -> Result { + let mut proc = Command::new("keybase"); + proc.args(&["status", "-json"]); + let output = proc.output().expect("No output"); + if !output.status.success() { + error!( + "keybase api fail: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(ErrorKind::GenericError("keybase api fail".to_owned()))? + } else { + let response: Value = + from_str(from_utf8(&output.stdout).expect("Bad output")).expect("Bad output"); + let err_msg = format!("{}", response["error"]["message"]); + if err_msg.len() > 0 && err_msg != "null" { + error!("status query got error: {}", err_msg); + } + + let username = response["Username"].as_str(); + if let Some(s) = username { + Ok(s.to_string()) + } else { + error!("keybase username query fail"); + Err(ErrorKind::GenericError( + "keybase username query fail".to_owned(), + ))? + } + } } /// Get all unread messages from a specific channel/topic and mark as read. -fn read_from_channel(channel: &str, topic: &str) -> Vec { +fn read_from_channel(channel: &str, topic: &str) -> Result, Error> { let payload = to_string(&json!({ "method": "read", "params": { @@ -80,55 +128,65 @@ fn read_from_channel(channel: &str, topic: &str) -> Vec { .unwrap(); let response = api_send(&payload); - let mut unread: Vec = Vec::new(); - for msg in response["result"]["messages"] - .as_array() - .unwrap_or(&vec![json!({})]) - .iter() - { - if (msg["msg"]["content"]["type"] == "text") && (msg["msg"]["unread"] == true) { - let message = msg["msg"]["content"]["text"]["body"].as_str().unwrap_or(""); - unread.push(message.to_owned()); + if let Ok(res) = response { + let mut unread: Vec = Vec::new(); + for msg in res["result"]["messages"] + .as_array() + .unwrap_or(&vec![json!({})]) + .iter() + { + if (msg["msg"]["content"]["type"] == "text") && (msg["msg"]["unread"] == true) { + let message = msg["msg"]["content"]["text"]["body"].as_str().unwrap_or(""); + unread.push(message.to_owned()); + } } + Ok(unread) + } else { + Err(ErrorKind::GenericError("keybase api fail".to_owned()))? } - unread } /// Get unread messages from all channels and mark as read. -fn get_unread(topic: &str) -> HashMap { +fn get_unread(topic: &str) -> Result, Error> { let payload = to_string(&json!({ "method": "list", "params": { "options": { - "topic_type": "dev", - }, - } + "topic_type": "dev", + }, } - )) + })) .unwrap(); let response = api_send(&payload); - let mut channels = HashSet::new(); - // Unfortunately the response does not contain the message body - // and a seperate call is needed for each channel - for msg in response["result"]["conversations"] - .as_array() - .unwrap_or(&vec![json!({})]) - .iter() - { - if (msg["unread"] == true) && (msg["channel"]["topic_name"] == topic) { - let channel = msg["channel"]["name"].as_str().unwrap(); - channels.insert(channel.to_string()); + if let Ok(res) = response { + let mut channels = HashSet::new(); + // Unfortunately the response does not contain the message body + // and a separate call is needed for each channel + for msg in res["result"]["conversations"] + .as_array() + .unwrap_or(&vec![json!({})]) + .iter() + { + if (msg["unread"] == true) && (msg["channel"]["topic_name"] == topic) { + let channel = msg["channel"]["name"].as_str().unwrap(); + channels.insert(channel.to_string()); + } } - } - let mut unread: HashMap = HashMap::new(); - for channel in channels.iter() { - let messages = read_from_channel(channel, topic); - for msg in messages { - unread.insert(msg, channel.to_string()); + let mut unread: HashMap = HashMap::new(); + for channel in channels.iter() { + let messages = read_from_channel(channel, topic); + if messages.is_err() { + break; + } + for msg in messages.unwrap() { + unread.insert(msg, channel.to_string()); + } } + Ok(unread) + } else { + Err(ErrorKind::GenericError("keybase api fail".to_owned()))? } - unread } /// Send a message to a keybase channel that self-destructs after ttl seconds. @@ -139,44 +197,79 @@ fn send(message: T, channel: &str, topic: &str, ttl: u16) -> bool "params": { "options": { "channel": { - "name": channel, "topic_name": topic, "topic_type": "dev" - }, - "message": { - "body": to_string(&message).unwrap() - }, - "exploding_lifetime": seconds - } - } - } - )) + "name": channel, "topic_name": topic, "topic_type": "dev" + }, + "message": { + "body": to_string(&message).unwrap() + }, + "exploding_lifetime": seconds + } + } + })) .unwrap(); let response = api_send(&payload); - match response["result"]["message"].as_str() { - Some("message sent") => true, - _ => false, + if let Ok(res) = response { + match res["result"]["message"].as_str() { + Some("message sent") => true, + _ => false, + } + } else { + false + } +} + +/// Send a notify to self that self-destructs after ttl minutes. +fn notify(message: &str, channel: &str, ttl: u16) -> bool { + let minutes = format!("{}m", ttl); + let payload = to_string(&json!({ + "method": "send", + "params": { + "options": { + "channel": { + "name": channel + }, + "message": { + "body": message + }, + "exploding_lifetime": minutes + } + } + })) + .unwrap(); + let response = api_send(&payload); + if let Ok(res) = response { + match res["result"]["message"].as_str() { + Some("message sent") => true, + _ => false, + } + } else { + false } } /// Listen for a message from a specific channel with topic SLATE_SIGNED for nseconds and return the first valid slate. fn poll(nseconds: u64, channel: &str) -> Option { let start = Instant::now(); - println!("Waiting for message from {}...", channel); + info!("Waiting for response message from @{}...", channel); while start.elapsed().as_secs() < nseconds { let unread = read_from_channel(channel, SLATE_SIGNED); - for msg in unread.iter() { + for msg in unread.unwrap().iter() { let blob = from_str::(msg); match blob { Ok(slate) => { - println!("Received message from {}", channel); + info!( + "keybase response message received from @{}, tx uuid: {}", + channel, slate.id, + ); return Some(slate); } Err(_) => (), } } - sleep(SLEEP_DURATION); + sleep(POLL_SLEEP_DURATION); } - println!( - "Did not receive reply from {} in {} seconds", + error!( + "No response from @{} in {} seconds. Grin send failed!", channel, nseconds ); None @@ -189,12 +282,21 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter { // Send a slate to a keybase username then wait for a response for TTL seconds. fn send_tx_sync(&self, addr: &str, slate: &Slate) -> Result { + // Limit only one recipient + if addr.matches(",").count() > 0 { + error!("Only one recipient is supported!"); + return Err(ErrorKind::GenericError("Tx rejected".to_owned()))?; + } + // Send original slate to recipient with the SLATE_NEW topic match send(slate, addr, SLATE_NEW, TTL) { true => (), false => return Err(ErrorKind::ClientCallback("Posting transaction slate"))?, } - println!("Sent new slate to {}", addr); + info!( + "tx request has been sent to @{}, tx uuid: {}", + addr, slate.id + ); // Wait for response from recipient with SLATE_SIGNED topic match poll(TTL as u64, addr) { Some(slate) => return Ok(slate), @@ -226,15 +328,39 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter { let wallet = instantiate_wallet(config.clone(), node_client, passphrase, account) .context(ErrorKind::WalletSeedDecryption)?; - println!("Listening for messages via keybase chat..."); + info!("Listening for transactions on keybase ..."); loop { // listen for messages from all channels with topic SLATE_NEW let unread = get_unread(SLATE_NEW); - for (msg, channel) in &unread { + if unread.is_err() { + error!("Listening exited for some keybase api failure"); + break; + } + for (msg, channel) in &unread.unwrap() { let blob = from_str::(msg); match blob { Ok(mut slate) => { - println!("Received message from channel {}", channel); + let tx_uuid = slate.id; + + // Reject multiple recipients channel for safety + { + if channel.matches(",").count() > 0 { + error!( + "Incoming tx initiated on channel \"{}\" is rejected, multiple recipients channel! amount: {}(g), tx uuid: {}", + channel, + slate.amount as f64 / 1000000000.0, + tx_uuid, + ); + continue; + } + } + + info!( + "tx initiated on channel \"{}\", to send you {}(g). tx uuid: {}", + channel, + slate.amount as f64 / 1000000000.0, + tx_uuid, + ); match controller::foreign_single_use(wallet.clone(), |api| { if let Err(e) = api.verify_slate_messages(&slate) { error!("Error validating participant messages: {}", e); @@ -246,22 +372,68 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter { // Reply to the same channel with topic SLATE_SIGNED Ok(_) => match send(slate, channel, SLATE_SIGNED, TTL) { true => { - println!("Returned slate to {}", channel); + notify_on_receive( + config.keybase_notify_ttl, + channel.to_string(), + tx_uuid.to_string(), + ); + debug!("Returned slate to @{} via keybase", channel); } false => { - println!("Failed to return slate to {}", channel); + error!("Failed to return slate to @{} via keybase. Incoming tx failed", channel); } }, Err(e) => { - println!("Error : {}", e); + error!( + "Error on receiving tx via keybase: {}. Incoming tx failed", + e + ); } } } Err(_) => (), } } - sleep(SLEEP_DURATION); + sleep(LISTEN_SLEEP_DURATION); } Ok(()) } } + +/// Notify in keybase on receiving a transaction +fn notify_on_receive(keybase_notify_ttl: u16, channel: String, tx_uuid: String) { + if keybase_notify_ttl > 0 { + let my_username = whoami(); + if let Ok(username) = my_username { + let split = channel.split(","); + let vec: Vec<&str> = split.collect(); + if vec.len() > 1 { + let receiver = username; + let sender = if vec[0] == receiver { + vec[1] + } else { + if vec[1] != receiver { + error!("keybase - channel doesn't include my username! channel: {}, username: {}", + channel, receiver + ); + } + vec[0] + }; + + let msg = format!( + "[grin wallet notice]: \ + you could have some coins received from @{}\n\ + Transaction Id: {}", + sender, tx_uuid + ); + notify(&msg, &receiver, keybase_notify_ttl); + info!( + "tx from @{} is done, please check on grin wallet. tx uuid: {}", + sender, tx_uuid, + ); + } + } else { + error!("keybase notification fail on whoami query"); + } + } +} diff --git a/wallet/src/command.rs b/wallet/src/command.rs index a59daecc2..1888de54e 100644 --- a/wallet/src/command.rs +++ b/wallet/src/command.rs @@ -254,11 +254,11 @@ pub fn send( let result = api.post_tx(&slate.tx, args.fluff); match result { Ok(_) => { - info!("Tx sent",); + info!("Tx sent ok",); return Ok(()); } Err(e) => { - error!("Tx not sent: {}", e); + error!("Tx sent fail: {}", e); return Err(e); } } diff --git a/wallet/src/types.rs b/wallet/src/types.rs index 3e25ae5fb..f05b606e1 100644 --- a/wallet/src/types.rs +++ b/wallet/src/types.rs @@ -57,6 +57,8 @@ pub struct WalletConfig { /// Whether to use the black background color scheme for command line /// if enabled, wallet command output color will be suitable for black background terminal pub dark_background_color_scheme: Option, + // The exploding lifetime (minutes) for keybase notification on coins received + pub keybase_notify_ttl: u16, } impl Default for WalletConfig { @@ -72,6 +74,7 @@ impl Default for WalletConfig { tls_certificate_file: None, tls_certificate_key: None, dark_background_color_scheme: Some(true), + keybase_notify_ttl: 1440, } } }