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
This commit is contained in:
Gary Yu 2018-12-25 08:05:24 +08:00 committed by GitHub
parent eebc2b208e
commit 12811a2445
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 248 additions and 70 deletions

View file

@ -404,9 +404,12 @@ fn comments() -> HashMap<String, String> {
.to_string(), .to_string(),
); );
retval.insert( 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(), .to_string(),
); );

View file

@ -28,7 +28,8 @@ use std::thread::sleep;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const TTL: u16 = 60; // TODO: Pass this as a parameter 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 // Which topic names to use for communication
const SLATE_NEW: &str = "grin_slate_new"; 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. /// 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<Value, Error> {
let mut proc = Command::new("keybase"); let mut proc = Command::new("keybase");
proc.args(&["chat", "api", "-m", &payload]); proc.args(&["chat", "api", "-m", &payload]);
let output = proc.output().expect("No output").stdout; let output = proc.output().expect("No output");
let response: Value = from_str(from_utf8(&output).expect("Bad output")).expect("Bad output"); if !output.status.success() {
response 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<String, Error> {
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. /// Get all unread messages from a specific channel/topic and mark as read.
fn read_from_channel(channel: &str, topic: &str) -> Vec<String> { fn read_from_channel(channel: &str, topic: &str) -> Result<Vec<String>, Error> {
let payload = to_string(&json!({ let payload = to_string(&json!({
"method": "read", "method": "read",
"params": { "params": {
@ -80,55 +128,65 @@ fn read_from_channel(channel: &str, topic: &str) -> Vec<String> {
.unwrap(); .unwrap();
let response = api_send(&payload); let response = api_send(&payload);
let mut unread: Vec<String> = Vec::new(); if let Ok(res) = response {
for msg in response["result"]["messages"] let mut unread: Vec<String> = Vec::new();
.as_array() for msg in res["result"]["messages"]
.unwrap_or(&vec![json!({})]) .as_array()
.iter() .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(""); if (msg["msg"]["content"]["type"] == "text") && (msg["msg"]["unread"] == true) {
unread.push(message.to_owned()); 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. /// Get unread messages from all channels and mark as read.
fn get_unread(topic: &str) -> HashMap<String, String> { fn get_unread(topic: &str) -> Result<HashMap<String, String>, Error> {
let payload = to_string(&json!({ let payload = to_string(&json!({
"method": "list", "method": "list",
"params": { "params": {
"options": { "options": {
"topic_type": "dev", "topic_type": "dev",
}, },
}
} }
)) }))
.unwrap(); .unwrap();
let response = api_send(&payload); let response = api_send(&payload);
let mut channels = HashSet::new(); if let Ok(res) = response {
// Unfortunately the response does not contain the message body let mut channels = HashSet::new();
// and a seperate call is needed for each channel // Unfortunately the response does not contain the message body
for msg in response["result"]["conversations"] // and a separate call is needed for each channel
.as_array() for msg in res["result"]["conversations"]
.unwrap_or(&vec![json!({})]) .as_array()
.iter() .unwrap_or(&vec![json!({})])
{ .iter()
if (msg["unread"] == true) && (msg["channel"]["topic_name"] == topic) { {
let channel = msg["channel"]["name"].as_str().unwrap(); if (msg["unread"] == true) && (msg["channel"]["topic_name"] == topic) {
channels.insert(channel.to_string()); let channel = msg["channel"]["name"].as_str().unwrap();
channels.insert(channel.to_string());
}
} }
} let mut unread: HashMap<String, String> = HashMap::new();
let mut unread: HashMap<String, String> = HashMap::new(); for channel in channels.iter() {
for channel in channels.iter() { let messages = read_from_channel(channel, topic);
let messages = read_from_channel(channel, topic); if messages.is_err() {
for msg in messages { break;
unread.insert(msg, channel.to_string()); }
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. /// Send a message to a keybase channel that self-destructs after ttl seconds.
@ -139,44 +197,79 @@ fn send<T: Serialize>(message: T, channel: &str, topic: &str, ttl: u16) -> bool
"params": { "params": {
"options": { "options": {
"channel": { "channel": {
"name": channel, "topic_name": topic, "topic_type": "dev" "name": channel, "topic_name": topic, "topic_type": "dev"
}, },
"message": { "message": {
"body": to_string(&message).unwrap() "body": to_string(&message).unwrap()
}, },
"exploding_lifetime": seconds "exploding_lifetime": seconds
} }
} }
} }))
))
.unwrap(); .unwrap();
let response = api_send(&payload); let response = api_send(&payload);
match response["result"]["message"].as_str() { if let Ok(res) = response {
Some("message sent") => true, match res["result"]["message"].as_str() {
_ => false, 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. /// 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<Slate> { fn poll(nseconds: u64, channel: &str) -> Option<Slate> {
let start = Instant::now(); let start = Instant::now();
println!("Waiting for message from {}...", channel); info!("Waiting for response message from @{}...", channel);
while start.elapsed().as_secs() < nseconds { while start.elapsed().as_secs() < nseconds {
let unread = read_from_channel(channel, SLATE_SIGNED); let unread = read_from_channel(channel, SLATE_SIGNED);
for msg in unread.iter() { for msg in unread.unwrap().iter() {
let blob = from_str::<Slate>(msg); let blob = from_str::<Slate>(msg);
match blob { match blob {
Ok(slate) => { Ok(slate) => {
println!("Received message from {}", channel); info!(
"keybase response message received from @{}, tx uuid: {}",
channel, slate.id,
);
return Some(slate); return Some(slate);
} }
Err(_) => (), Err(_) => (),
} }
} }
sleep(SLEEP_DURATION); sleep(POLL_SLEEP_DURATION);
} }
println!( error!(
"Did not receive reply from {} in {} seconds", "No response from @{} in {} seconds. Grin send failed!",
channel, nseconds channel, nseconds
); );
None None
@ -189,12 +282,21 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter {
// Send a slate to a keybase username then wait for a response for TTL seconds. // 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<Slate, Error> { fn send_tx_sync(&self, addr: &str, slate: &Slate) -> Result<Slate, Error> {
// 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 // Send original slate to recipient with the SLATE_NEW topic
match send(slate, addr, SLATE_NEW, TTL) { match send(slate, addr, SLATE_NEW, TTL) {
true => (), true => (),
false => return Err(ErrorKind::ClientCallback("Posting transaction slate"))?, 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 // Wait for response from recipient with SLATE_SIGNED topic
match poll(TTL as u64, addr) { match poll(TTL as u64, addr) {
Some(slate) => return Ok(slate), Some(slate) => return Ok(slate),
@ -226,15 +328,39 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter {
let wallet = instantiate_wallet(config.clone(), node_client, passphrase, account) let wallet = instantiate_wallet(config.clone(), node_client, passphrase, account)
.context(ErrorKind::WalletSeedDecryption)?; .context(ErrorKind::WalletSeedDecryption)?;
println!("Listening for messages via keybase chat..."); info!("Listening for transactions on keybase ...");
loop { loop {
// listen for messages from all channels with topic SLATE_NEW // listen for messages from all channels with topic SLATE_NEW
let unread = get_unread(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::<Slate>(msg); let blob = from_str::<Slate>(msg);
match blob { match blob {
Ok(mut slate) => { 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| { match controller::foreign_single_use(wallet.clone(), |api| {
if let Err(e) = api.verify_slate_messages(&slate) { if let Err(e) = api.verify_slate_messages(&slate) {
error!("Error validating participant messages: {}", e); error!("Error validating participant messages: {}", e);
@ -246,22 +372,68 @@ impl WalletCommAdapter for KeybaseWalletCommAdapter {
// Reply to the same channel with topic SLATE_SIGNED // Reply to the same channel with topic SLATE_SIGNED
Ok(_) => match send(slate, channel, SLATE_SIGNED, TTL) { Ok(_) => match send(slate, channel, SLATE_SIGNED, TTL) {
true => { 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 => { false => {
println!("Failed to return slate to {}", channel); error!("Failed to return slate to @{} via keybase. Incoming tx failed", channel);
} }
}, },
Err(e) => { Err(e) => {
println!("Error : {}", e); error!(
"Error on receiving tx via keybase: {}. Incoming tx failed",
e
);
} }
} }
} }
Err(_) => (), Err(_) => (),
} }
} }
sleep(SLEEP_DURATION); sleep(LISTEN_SLEEP_DURATION);
} }
Ok(()) 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");
}
}
}

View file

@ -254,11 +254,11 @@ pub fn send(
let result = api.post_tx(&slate.tx, args.fluff); let result = api.post_tx(&slate.tx, args.fluff);
match result { match result {
Ok(_) => { Ok(_) => {
info!("Tx sent",); info!("Tx sent ok",);
return Ok(()); return Ok(());
} }
Err(e) => { Err(e) => {
error!("Tx not sent: {}", e); error!("Tx sent fail: {}", e);
return Err(e); return Err(e);
} }
} }

View file

@ -57,6 +57,8 @@ pub struct WalletConfig {
/// Whether to use the black background color scheme for command line /// Whether to use the black background color scheme for command line
/// if enabled, wallet command output color will be suitable for black background terminal /// if enabled, wallet command output color will be suitable for black background terminal
pub dark_background_color_scheme: Option<bool>, pub dark_background_color_scheme: Option<bool>,
// The exploding lifetime (minutes) for keybase notification on coins received
pub keybase_notify_ttl: u16,
} }
impl Default for WalletConfig { impl Default for WalletConfig {
@ -72,6 +74,7 @@ impl Default for WalletConfig {
tls_certificate_file: None, tls_certificate_file: None,
tls_certificate_key: None, tls_certificate_key: None,
dark_background_color_scheme: Some(true), dark_background_color_scheme: Some(true),
keybase_notify_ttl: 1440,
} }
} }
} }