From 4bebc9a0fe82ca4bb9f620c84f2de2f7793b2cef Mon Sep 17 00:00:00 2001 From: Aurelia Date: Thu, 19 Dec 2024 18:12:16 +0100 Subject: [PATCH 1/4] [CLI] claim shared recovery device. --- cli/src/commands/invite/claim.rs | 160 ++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/invite/claim.rs b/cli/src/commands/invite/claim.rs index 9df838199bb..86cb77e199d 100644 --- a/cli/src/commands/invite/claim.rs +++ b/cli/src/commands/invite/claim.rs @@ -11,8 +11,16 @@ use libparsec::{ }, ClientConfig, DeviceAccessStrategy, ParsecInvitationAddr, }; +use libparsec_client::{ + ShamirRecoveryClaimInProgress1Ctx, ShamirRecoveryClaimInProgress2Ctx, + ShamirRecoveryClaimInProgress3Ctx, ShamirRecoveryClaimInitialCtx, + ShamirRecoveryClaimMaybeFinalizeCtx, ShamirRecoveryClaimMaybeRecoverDeviceCtx, + ShamirRecoveryClaimPickRecipientCtx, ShamirRecoveryClaimRecoverDeviceCtx, + ShamirRecoveryClaimShare, +}; use crate::utils::*; +use dialoguer::Select; crate::clap_parser_with_shared_opts_builder!( #[with = config_dir, data_dir, password_stdin] @@ -69,9 +77,59 @@ pub async fn main(args: Args) -> anyhow::Result<()> { let ctx = step4_device(ctx).await?; save_device(ctx, save_mode).await } - AnyClaimRetrievedInfoCtx::ShamirRecovery(_) => Err(anyhow::anyhow!( - "Shamir recovery invitation is not supported yet" - )), + AnyClaimRetrievedInfoCtx::ShamirRecovery(ctx) => { + let mut maybe_pick_ctx = Some(ctx); + loop { + let pick_ctx = maybe_pick_ctx.ok_or(anyhow::anyhow!("todo"))?; + let ctx = step05_shamir(&pick_ctx)?; + let ctx = step1_shamir(ctx).await?; + let ctx = step2_shamir(ctx).await?; + let ctx = step3_shamir(ctx).await?; + let share_ctx = step4_shamir(ctx).await?; + let maybe = pick_ctx.add_share(share_ctx)?; + match maybe { + ShamirRecoveryClaimMaybeRecoverDeviceCtx::RecoverDevice( + shamir_recovery_claim_recover_device_ctx, + ) => { + maybe_pick_ctx = None; + let ctx = step5_shamir(shamir_recovery_claim_recover_device_ctx).await?; + + match ctx { + ShamirRecoveryClaimMaybeFinalizeCtx::Offline(..) => { + let retry = Select::new() + .with_prompt("Unable to join server, do you want to retry") + .items(&["yes", "no"]) + .interact()?; + + if retry == 0 { + continue; + } else { + break; + } + } + // happy path + ShamirRecoveryClaimMaybeFinalizeCtx::Finalize( + shamir_recovery_claim_finalize_ctx, + ) => { + let key_file = + shamir_recovery_claim_finalize_ctx.get_default_key_file(); + let access_strategy = get_access_strategy(key_file, save_mode)?; + shamir_recovery_claim_finalize_ctx + .save_local_device(&access_strategy) + .await?; + break; + } + } + } + // need more shares + ShamirRecoveryClaimMaybeRecoverDeviceCtx::PickRecipient( + shamir_recovery_claim_pick_recipient_ctx, + ) => maybe_pick_ctx = Some(shamir_recovery_claim_pick_recipient_ctx), + } + } + + Ok(()) + } } } @@ -89,6 +147,19 @@ async fn step0( Ok(ctx) } +/// Step 0.5: choose recipient +fn step05_shamir( + ctx: &ShamirRecoveryClaimPickRecipientCtx, +) -> anyhow::Result { + let recipients = ctx.recipients(); + let human_recipients: Vec<_> = recipients.iter().map(|r| r.human_handle.clone()).collect(); + let selection = Select::new() + .with_prompt("Choose a person to contact first") + .items(&human_recipients) + .interact()?; + Ok(ctx.pick_recipient(recipients[selection].user_id)?) +} + /// Step 1: wait peer async fn step1_user(ctx: UserClaimInitialCtx) -> anyhow::Result { println!( @@ -121,6 +192,24 @@ async fn step1_device(ctx: DeviceClaimInitialCtx) -> anyhow::Result anyhow::Result { + println!( + "Invitation greeter: {YELLOW}{}{RESET}", + ctx.greeter_human_handle() + ); + + let mut handle = start_spinner("Waiting the greeter to start the invitation procedure".into()); + + let ctx = ctx.do_wait_peer().await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + /// Step 2: signify trust async fn step2_user(ctx: UserClaimInProgress1Ctx) -> anyhow::Result { let mut input = String::new(); @@ -153,6 +242,24 @@ async fn step2_device(ctx: DeviceClaimInProgress1Ctx) -> anyhow::Result anyhow::Result { + let mut input = String::new(); + let sas_codes = ctx.generate_greeter_sas_choices(3); + + for (i, sas_code) in sas_codes.iter().enumerate() { + println!(" {i} - {YELLOW}{sas_code}{RESET}") + } + + println!("Select code provided by greeter (0, 1, 2)"); + + choose_sas_code(&mut input, &sas_codes, ctx.greeter_sas())?; + + Ok(ctx.do_signify_trust().await?) +} + /// Step 3: wait peer trust async fn step3_user(ctx: UserClaimInProgress2Ctx) -> anyhow::Result { println!( @@ -169,6 +276,24 @@ async fn step3_user(ctx: UserClaimInProgress2Ctx) -> anyhow::Result anyhow::Result { + println!( + "Code to provide to greeter: {YELLOW}{}{RESET}", + ctx.claimer_sas() + ); + + let mut handle = start_spinner("Waiting for greeter".into()); + + let ctx = ctx.do_wait_peer_trust().await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + /// Step 3: wait peer trust async fn step3_device(ctx: DeviceClaimInProgress2Ctx) -> anyhow::Result { println!( @@ -214,6 +339,35 @@ async fn step4_device(ctx: DeviceClaimInProgress3Ctx) -> anyhow::Result anyhow::Result { + let mut handle = start_spinner("Waiting for greeter".into()); + + let ctx = ctx.do_recover_share().await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + +/// Step 5: recover device +async fn step5_shamir( + ctx: ShamirRecoveryClaimRecoverDeviceCtx, +) -> anyhow::Result { + let mut input = String::new(); + let device_label = choose_device_label(&mut input)?; + + let mut handle = start_spinner("Waiting for greeter".into()); + + let ctx = ctx.recover_device(device_label).await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + fn get_access_strategy( key_file: PathBuf, save_mode: SaveMode, From 44b946faba8fe717e88ef77828163a092f66762d Mon Sep 17 00:00:00 2001 From: Aurelia Date: Thu, 19 Dec 2024 18:26:06 +0100 Subject: [PATCH 2/4] [CLI] Implement greet shared recovery --- cli/src/commands/invite/greet.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/invite/greet.rs b/cli/src/commands/invite/greet.rs index 42621f109d8..814e12239f1 100644 --- a/cli/src/commands/invite/greet.rs +++ b/cli/src/commands/invite/greet.rs @@ -11,7 +11,10 @@ use libparsec::{ }, InvitationToken, }; -use libparsec_client::Client; +use libparsec_client::{ + Client, ShamirRecoveryGreetInProgress1Ctx, ShamirRecoveryGreetInProgress2Ctx, + ShamirRecoveryGreetInProgress3Ctx, ShamirRecoveryGreetInitialCtx, +}; use crate::utils::*; @@ -45,15 +48,26 @@ pub async fn device_greet(args: Args, client: &StartedClient) -> anyhow::Result< InviteListItem::Device { token, .. } => { let ctx = client.start_device_invitation_greet(token); - let ctx = step1_device(ctx).await?; + let ctx: DeviceGreetInProgress1Ctx = step1_device(ctx).await?; let ctx = step2_device(ctx).await?; let ctx = step3_device(ctx).await?; let ctx = step4_device(ctx).await?; step5_device(ctx).await } - InviteListItem::ShamirRecovery { .. } => Err(anyhow::anyhow!( - "Shamir recovery invitation is not supported yet" - )), + InviteListItem::ShamirRecovery { + token, + claimer_user_id, + .. + } => { + let ctx = client + .start_shamir_recovery_invitation_greet(token, claimer_user_id) + .await?; + + let ctx = step1_shamir(ctx).await?; + let ctx = step2_shamir(ctx).await?; + let ctx = step3_shamir(ctx).await?; + step4_shamir(ctx).await + } } } @@ -102,6 +116,19 @@ async fn step1_device(ctx: DeviceGreetInitialCtx) -> anyhow::Result anyhow::Result { + let mut handle = start_spinner("Waiting for claimer".into()); + + let ctx = ctx.do_wait_peer().await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + /// Step 2: wait peer trust async fn step2_user(ctx: UserGreetInProgress1Ctx) -> anyhow::Result { println!( @@ -134,6 +161,24 @@ async fn step2_device(ctx: DeviceGreetInProgress1Ctx) -> anyhow::Result anyhow::Result { + println!( + "Code to provide to claimer: {YELLOW}{}{RESET}", + ctx.greeter_sas() + ); + + let mut handle = start_spinner("Waiting for claimer".into()); + + let ctx = ctx.do_wait_peer_trust().await?; + + handle.stop_with_newline(); + + Ok(ctx) +} + /// Step 3: signify trust async fn step3_user(ctx: UserGreetInProgress2Ctx) -> anyhow::Result { let mut input = String::new(); @@ -164,6 +209,23 @@ async fn step3_device(ctx: DeviceGreetInProgress2Ctx) -> anyhow::Result anyhow::Result { + let mut input = String::new(); + let sas_codes = ctx.generate_claimer_sas_choices(3); + for (i, sas_code) in sas_codes.iter().enumerate() { + println!(" {i} - {YELLOW}{sas_code}{RESET}") + } + + println!("Select code provided by claimer (0, 1, 2)"); + + choose_sas_code(&mut input, &sas_codes, ctx.claimer_sas())?; + + Ok(ctx.do_signify_trust().await?) +} + /// Step 4: get claim requests async fn step4_user(ctx: UserGreetInProgress3Ctx) -> anyhow::Result { Ok(ctx.do_get_claim_requests().await?) @@ -174,6 +236,11 @@ async fn step4_device(ctx: DeviceGreetInProgress3Ctx) -> anyhow::Result anyhow::Result<()> { + Ok(ctx.do_send_share().await?) +} + /// Step 5: create new user async fn step5_user(ctx: UserGreetInProgress4Ctx) -> anyhow::Result<()> { let mut input = String::new(); From 89de38c89479d3ae0f65db3eb19d368cd5178b58 Mon Sep 17 00:00:00 2001 From: Aurelia Date: Thu, 19 Dec 2024 19:01:36 +0100 Subject: [PATCH 3/4] [CLI] fixup shared recovery --- cli/src/commands/invite/claim.rs | 98 +++++++++---------- cli/src/commands/invite/greet.rs | 25 +++-- libparsec/crates/client/src/invite/claimer.rs | 7 ++ libparsec/crates/types/src/error.rs | 2 +- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/cli/src/commands/invite/claim.rs b/cli/src/commands/invite/claim.rs index 86cb77e199d..b11804bcba5 100644 --- a/cli/src/commands/invite/claim.rs +++ b/cli/src/commands/invite/claim.rs @@ -2,6 +2,7 @@ use std::{path::PathBuf, sync::Arc}; +use anyhow::anyhow; use libparsec::{ internal::{ claimer_retrieve_info, AnyClaimRetrievedInfoCtx, DeviceClaimFinalizeCtx, @@ -20,7 +21,7 @@ use libparsec_client::{ }; use crate::utils::*; -use dialoguer::Select; +use dialoguer::{Input, Select}; crate::clap_parser_with_shared_opts_builder!( #[with = config_dir, data_dir, password_stdin] @@ -78,10 +79,10 @@ pub async fn main(args: Args) -> anyhow::Result<()> { save_device(ctx, save_mode).await } AnyClaimRetrievedInfoCtx::ShamirRecovery(ctx) => { - let mut maybe_pick_ctx = Some(ctx); - loop { - let pick_ctx = maybe_pick_ctx.ok_or(anyhow::anyhow!("todo"))?; - let ctx = step05_shamir(&pick_ctx)?; + let mut pick_ctx = ctx; + + let mut device_ctx = loop { + let ctx = shamir_pick_recipient(&pick_ctx)?; let ctx = step1_shamir(ctx).await?; let ctx = step2_shamir(ctx).await?; let ctx = step3_shamir(ctx).await?; @@ -91,42 +92,41 @@ pub async fn main(args: Args) -> anyhow::Result<()> { ShamirRecoveryClaimMaybeRecoverDeviceCtx::RecoverDevice( shamir_recovery_claim_recover_device_ctx, ) => { - maybe_pick_ctx = None; - let ctx = step5_shamir(shamir_recovery_claim_recover_device_ctx).await?; - - match ctx { - ShamirRecoveryClaimMaybeFinalizeCtx::Offline(..) => { - let retry = Select::new() - .with_prompt("Unable to join server, do you want to retry") - .items(&["yes", "no"]) - .interact()?; - - if retry == 0 { - continue; - } else { - break; - } - } - // happy path - ShamirRecoveryClaimMaybeFinalizeCtx::Finalize( - shamir_recovery_claim_finalize_ctx, - ) => { - let key_file = - shamir_recovery_claim_finalize_ctx.get_default_key_file(); - let access_strategy = get_access_strategy(key_file, save_mode)?; - shamir_recovery_claim_finalize_ctx - .save_local_device(&access_strategy) - .await?; - break; - } - } + break shamir_recovery_claim_recover_device_ctx; } // need more shares ShamirRecoveryClaimMaybeRecoverDeviceCtx::PickRecipient( shamir_recovery_claim_pick_recipient_ctx, - ) => maybe_pick_ctx = Some(shamir_recovery_claim_pick_recipient_ctx), + ) => pick_ctx = shamir_recovery_claim_pick_recipient_ctx, + } + }; + + let final_ctx = loop { + let ctx = step5_shamir(device_ctx).await?; + match ctx { + ShamirRecoveryClaimMaybeFinalizeCtx::Offline(ctx) => { + let retry = Select::new() + .with_prompt("Unable to join server, do you want to retry ?") + .items(&["yes", "no"]) + .interact()?; + + if retry == 0 { + device_ctx = ctx; + continue; + } else { + return Err(anyhow!("Server offline, try again later.")); + } + } + ShamirRecoveryClaimMaybeFinalizeCtx::Finalize( + shamir_recovery_claim_finalize_ctx, + ) => { + break shamir_recovery_claim_finalize_ctx; + } } - } + }; + let key_file = final_ctx.get_default_key_file(); + let access_strategy = get_access_strategy(key_file, save_mode)?; + final_ctx.save_local_device(&access_strategy).await?; Ok(()) } @@ -148,13 +148,13 @@ async fn step0( } /// Step 0.5: choose recipient -fn step05_shamir( +fn shamir_pick_recipient( ctx: &ShamirRecoveryClaimPickRecipientCtx, ) -> anyhow::Result { - let recipients = ctx.recipients(); + let recipients = ctx.yet_to_contact_recipients(); let human_recipients: Vec<_> = recipients.iter().map(|r| r.human_handle.clone()).collect(); let selection = Select::new() - .with_prompt("Choose a person to contact first") + .with_prompt("Choose a person to contact now") .items(&human_recipients) .interact()?; Ok(ctx.pick_recipient(recipients[selection].user_id)?) @@ -246,18 +246,17 @@ async fn step2_device(ctx: DeviceClaimInProgress1Ctx) -> anyhow::Result anyhow::Result { - let mut input = String::new(); let sas_codes = ctx.generate_greeter_sas_choices(3); - for (i, sas_code) in sas_codes.iter().enumerate() { - println!(" {i} - {YELLOW}{sas_code}{RESET}") + let selected_sas = Select::new() + .items(&sas_codes) + .with_prompt("Select code provided by greeter") + .interact()?; + if &sas_codes[selected_sas] != ctx.greeter_sas() { + Err(anyhow!("Invalid SAS code")) + } else { + Ok(ctx.do_signify_trust().await?) } - - println!("Select code provided by greeter (0, 1, 2)"); - - choose_sas_code(&mut input, &sas_codes, ctx.greeter_sas())?; - - Ok(ctx.do_signify_trust().await?) } /// Step 3: wait peer trust @@ -356,8 +355,7 @@ async fn step4_shamir( async fn step5_shamir( ctx: ShamirRecoveryClaimRecoverDeviceCtx, ) -> anyhow::Result { - let mut input = String::new(); - let device_label = choose_device_label(&mut input)?; + let device_label = Input::new().with_prompt("Enter device label").interact()?; let mut handle = start_spinner("Waiting for greeter".into()); diff --git a/cli/src/commands/invite/greet.rs b/cli/src/commands/invite/greet.rs index 814e12239f1..a1c7abc7693 100644 --- a/cli/src/commands/invite/greet.rs +++ b/cli/src/commands/invite/greet.rs @@ -1,6 +1,7 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS use anyhow::Context; +use dialoguer::Select; use libparsec::{ authenticated_cmds::latest::invite_list::InviteListItem, internal::{ @@ -33,6 +34,12 @@ pub async fn device_greet(args: Args, client: &StartedClient) -> anyhow::Result< let Args { token, .. } = args; log::trace!("Greeting invitation"); + { + let mut spinner = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + spinner.stop_with_symbol(GREEN_CHECKMARK); + } + let invitation = step0(client, token).await?; match invitation { @@ -83,6 +90,7 @@ async fn step0( let invitation = match invitations.into_iter().find(|invitation| match invitation { InviteListItem::User { token, .. } if *token == invitation_token => true, InviteListItem::Device { token, .. } if *token == invitation_token => true, + InviteListItem::ShamirRecovery { token, .. } if *token == invitation_token => true, _ => false, }) { Some(invitation) => invitation, @@ -213,17 +221,16 @@ async fn step3_device(ctx: DeviceGreetInProgress2Ctx) -> anyhow::Result anyhow::Result { - let mut input = String::new(); let sas_codes = ctx.generate_claimer_sas_choices(3); - for (i, sas_code) in sas_codes.iter().enumerate() { - println!(" {i} - {YELLOW}{sas_code}{RESET}") + let selected_sas = Select::new() + .items(&sas_codes) + .with_prompt("Select code provided by claimer") + .interact()?; + if &sas_codes[selected_sas] != ctx.claimer_sas() { + Err(anyhow::anyhow!("Invalid SAS code")) + } else { + Ok(ctx.do_signify_trust().await?) } - - println!("Select code provided by claimer (0, 1, 2)"); - - choose_sas_code(&mut input, &sas_codes, ctx.claimer_sas())?; - - Ok(ctx.do_signify_trust().await?) } /// Step 4: get claim requests diff --git a/libparsec/crates/client/src/invite/claimer.rs b/libparsec/crates/client/src/invite/claimer.rs index 0bdd3f357ce..d50c5536a9e 100644 --- a/libparsec/crates/client/src/invite/claimer.rs +++ b/libparsec/crates/client/src/invite/claimer.rs @@ -356,6 +356,13 @@ impl ShamirRecoveryClaimPickRecipientCtx { self.shamir_recovery_created_on } + pub fn yet_to_contact_recipients(&self) -> Vec<&ShamirRecoveryRecipient> { + self.recipients + .iter() + .filter(|r| !self.shares.contains_key(&r.user_id)) + .collect() + } + pub fn is_recoverable(&self) -> bool { self.recipients .iter() diff --git a/libparsec/crates/types/src/error.rs b/libparsec/crates/types/src/error.rs index 08cd7e3f22a..584c2838030 100644 --- a/libparsec/crates/types/src/error.rs +++ b/libparsec/crates/types/src/error.rs @@ -13,7 +13,7 @@ pub enum DataError { #[error("Invalid encryption")] Decryption, - #[error("Invalid serialization: format {} step {step}", match .format { Some(format) => format!("{}", format), None => "".to_string() })] + #[error("Invalid serialization: format {} step <{step}>", match .format { Some(format) => format!("{}", format), None => "".to_string() })] BadSerialization { format: Option, step: &'static str, From 38f17589b49f22c8e65e16bbe8ff24eb51ad87b1 Mon Sep 17 00:00:00 2001 From: Aurelia Date: Thu, 9 Jan 2025 09:30:59 +0100 Subject: [PATCH 4/4] Add invitation dance cli test for shared recovery. --- cli/src/commands/invite/claim.rs | 26 ++- cli/src/commands/invite/greet.rs | 17 +- cli/src/commands/invite/list.rs | 7 +- cli/src/utils.rs | 2 +- cli/tests/integration/invitations/mod.rs | 1 + .../invitations/shared_recovery.rs | 191 ++++++++++++++++++ cli/tests/integration/mod.rs | 72 +++++++ .../integration/shared_recovery/delete.rs | 4 +- cli/tests/integration/shared_recovery/list.rs | 4 +- cli/tests/integration/shared_recovery/mod.rs | 52 ----- libparsec/crates/client/src/invite/claimer.rs | 2 +- 11 files changed, 299 insertions(+), 79 deletions(-) create mode 100644 cli/tests/integration/invitations/shared_recovery.rs diff --git a/cli/src/commands/invite/claim.rs b/cli/src/commands/invite/claim.rs index b11804bcba5..9e3bc05cbee 100644 --- a/cli/src/commands/invite/claim.rs +++ b/cli/src/commands/invite/claim.rs @@ -106,14 +106,17 @@ pub async fn main(args: Args) -> anyhow::Result<()> { match ctx { ShamirRecoveryClaimMaybeFinalizeCtx::Offline(ctx) => { let retry = Select::new() + .default(0) .with_prompt("Unable to join server, do you want to retry ?") .items(&["yes", "no"]) .interact()?; if retry == 0 { + // yes device_ctx = ctx; continue; } else { + // no return Err(anyhow!("Server offline, try again later.")); } } @@ -142,7 +145,7 @@ async fn step0( let ctx = claimer_retrieve_info(Arc::new(config.into()), addr, None).await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -151,9 +154,10 @@ async fn step0( fn shamir_pick_recipient( ctx: &ShamirRecoveryClaimPickRecipientCtx, ) -> anyhow::Result { - let recipients = ctx.yet_to_contact_recipients(); - let human_recipients: Vec<_> = recipients.iter().map(|r| r.human_handle.clone()).collect(); + let recipients = ctx.recipients_without_a_share(); + let human_recipients: Vec<&_> = recipients.iter().map(|r| &r.human_handle).collect(); let selection = Select::new() + .default(0) .with_prompt("Choose a person to contact now") .items(&human_recipients) .interact()?; @@ -201,11 +205,14 @@ async fn step1_shamir( ctx.greeter_human_handle() ); - let mut handle = start_spinner("Waiting the greeter to start the invitation procedure".into()); + let mut handle = start_spinner(format!( + "Waiting the greeter {} to start the invitation procedure", + ctx.greeter_human_handle() + )); let ctx = ctx.do_wait_peer().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -249,6 +256,7 @@ async fn step2_shamir( let sas_codes = ctx.generate_greeter_sas_choices(3); let selected_sas = Select::new() + .default(0) .items(&sas_codes) .with_prompt("Select code provided by greeter") .interact()?; @@ -288,7 +296,7 @@ async fn step3_shamir( let ctx = ctx.do_wait_peer_trust().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -346,7 +354,7 @@ async fn step4_shamir( let ctx = ctx.do_recover_share().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -357,11 +365,11 @@ async fn step5_shamir( ) -> anyhow::Result { let device_label = Input::new().with_prompt("Enter device label").interact()?; - let mut handle = start_spinner("Waiting for greeter".into()); + let mut handle = start_spinner("Recovering device".into()); let ctx = ctx.recover_device(device_label).await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } diff --git a/cli/src/commands/invite/greet.rs b/cli/src/commands/invite/greet.rs index a1c7abc7693..c212aa3fe41 100644 --- a/cli/src/commands/invite/greet.rs +++ b/cli/src/commands/invite/greet.rs @@ -61,14 +61,8 @@ pub async fn device_greet(args: Args, client: &StartedClient) -> anyhow::Result< let ctx = step4_device(ctx).await?; step5_device(ctx).await } - InviteListItem::ShamirRecovery { - token, - claimer_user_id, - .. - } => { - let ctx = client - .start_shamir_recovery_invitation_greet(token, claimer_user_id) - .await?; + InviteListItem::ShamirRecovery { token, .. } => { + let ctx = client.start_shamir_recovery_invitation_greet(token).await?; let ctx = step1_shamir(ctx).await?; let ctx = step2_shamir(ctx).await?; @@ -97,7 +91,7 @@ async fn step0( None => return Err(anyhow::anyhow!("Invitation not found")), }; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(invitation) } @@ -132,7 +126,7 @@ async fn step1_shamir( let ctx = ctx.do_wait_peer().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -182,7 +176,7 @@ async fn step2_shamir( let ctx = ctx.do_wait_peer_trust().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -223,6 +217,7 @@ async fn step3_shamir( ) -> anyhow::Result { let sas_codes = ctx.generate_claimer_sas_choices(3); let selected_sas = Select::new() + .default(0) .items(&sas_codes) .with_prompt("Select code provided by claimer") .interact()?; diff --git a/cli/src/commands/invite/list.rs b/cli/src/commands/invite/list.rs index eba75448ba8..22acf1fbe3d 100644 --- a/cli/src/commands/invite/list.rs +++ b/cli/src/commands/invite/list.rs @@ -13,6 +13,11 @@ crate::build_main_with_client!(main, list_invite); pub async fn list_invite(_args: Args, client: &StartedClient) -> anyhow::Result<()> { log::trace!("Listing invitations"); + { + let mut spinner = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + spinner.stop_with_symbol(GREEN_CHECKMARK); + } let mut handle = start_spinner("Listing invitations".into()); @@ -31,7 +36,7 @@ pub async fn list_invite(_args: Args, client: &StartedClient) -> anyhow::Result< status, token, .. - } => (token, status, format!("user (email={claimer_email}")), + } => (token, status, format!("user (email={claimer_email})")), InviteListItem::Device { status, token, .. } => (token, status, "device".into()), InviteListItem::ShamirRecovery { status, diff --git a/cli/src/utils.rs b/cli/src/utils.rs index ce05684dc64..bbf6618c3c7 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -24,7 +24,7 @@ pub const GREEN: &str = "\x1B[92m"; pub const RED: &str = "\x1B[91m"; pub const RESET: &str = "\x1B[39m"; pub const YELLOW: &str = "\x1B[33m"; -pub const GREEN_CHECKMARK: &str = "\x1B[92m🗸\x1B[39m"; +pub const GREEN_CHECKMARK: &str = "\x1B[92m✔\x1B[39m"; pub const BULLET_CHAR: &str = "•"; pub fn format_devices( diff --git a/cli/tests/integration/invitations/mod.rs b/cli/tests/integration/invitations/mod.rs index ea86be7314a..05b363aabb5 100644 --- a/cli/tests/integration/invitations/mod.rs +++ b/cli/tests/integration/invitations/mod.rs @@ -1,4 +1,5 @@ mod cancel; mod device; mod list; +mod shared_recovery; mod user; diff --git a/cli/tests/integration/invitations/shared_recovery.rs b/cli/tests/integration/invitations/shared_recovery.rs new file mode 100644 index 00000000000..144f4ec004f --- /dev/null +++ b/cli/tests/integration/invitations/shared_recovery.rs @@ -0,0 +1,191 @@ +use std::sync::{Arc, Mutex}; + +use libparsec::{ + authenticated_cmds::v4::invite_new_shamir_recovery, get_default_config_dir, tmp_path, + AuthenticatedCmds, InvitationType, ParsecInvitationAddr, ProxyConfig, TmpPath, +}; +use rexpect::{session::PtySession, spawn}; + +use crate::{ + integration_tests::{bootstrap_cli_test, shared_recovery_create}, + testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}, +}; + +macro_rules! match_sas_code { + ($locked:ident, $sas_code:ident) => { + $locked.read_line().unwrap(); //empty line + let first = dbg!($locked.read_line().unwrap()); + let second = dbg!($locked.read_line().unwrap()); + let third = dbg!($locked.read_line().unwrap()); + + if $sas_code == first[first.len() - 4..] { + $locked.send_line("").unwrap(); + } else if $sas_code == second[second.len() - 4..] { + $locked.send_line("j").unwrap(); + } else if $sas_code == third[third.len() - 4..] { + $locked.send_line("jj").unwrap(); + } else { + panic!("no corresponding sas code available") + } + }; +} + +#[rstest::rstest] +#[tokio::test] +async fn invite_shared_recovery_dance(tmp_path: TmpPath) { + let (_, TestOrganization { alice, bob, .. }, _) = bootstrap_cli_test(&tmp_path).await.unwrap(); + + shared_recovery_create(&alice, &bob, None); + let cmds = AuthenticatedCmds::new( + &get_default_config_dir(), + bob.clone(), + ProxyConfig::new_from_env().unwrap(), + ) + .unwrap(); + + let rep = cmds + .send(invite_new_shamir_recovery::Req { + send_email: false, + claimer_user_id: alice.user_id, + }) + .await + .unwrap(); + + let invitation_addr = match rep { + invite_new_shamir_recovery::InviteNewShamirRecoveryRep::Ok { token, .. } => { + ParsecInvitationAddr::new( + alice.organization_addr.clone(), + alice.organization_id().clone(), + InvitationType::ShamirRecovery, + token, + ) + } + rep => { + panic!("Server refused to create user invitation: {rep:?}"); + } + }; + + let token = invitation_addr.token(); + + // spawn greeter thread + let mut cmd_greeter = assert_cmd::Command::cargo_bin("parsec-cli").unwrap(); + cmd_greeter.args([ + "invite", + "greet", + "--device", + &bob.device_id.hex(), + &token.hex().to_string(), + ]); + + let program_greeter = cmd_greeter.get_program().to_str().unwrap().to_string(); + let program_greeter = cmd_greeter + .get_args() + .fold(program_greeter, |acc, s| format!("{acc} {s:?}")); + + let p_greeter = Arc::new(Mutex::new( + spawn(&dbg!(program_greeter), Some(1000)).unwrap(), + )); + + // spawn claimer thread + + let mut cmd_claimer = assert_cmd::Command::cargo_bin("parsec-cli").unwrap(); + cmd_claimer.args(["invite", "claim", invitation_addr.to_url().as_ref()]); + + let program_claimer = cmd_claimer.get_program().to_str().unwrap().to_string(); + let program_claimer = cmd_claimer + .get_args() + .fold(program_claimer, |acc, s| format!("{acc} {s:?}")); + + let p_claimer = Arc::new(Mutex::new( + spawn(&dbg!(program_claimer), Some(10_000)).unwrap(), + )); + + // retrieve greeter code + let greeter_cloned = p_greeter.clone(); + let greeter = tokio::task::spawn(async move { + let mut locked = greeter_cloned.lock().unwrap(); + + locked.exp_string("Enter password for the device:").unwrap(); + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_string("Waiting for claimer").unwrap(); + }); + let claimer_cloned = p_claimer.clone(); + + let claimer = tokio::task::spawn(async move { + let mut locked = claimer_cloned.lock().unwrap(); + locked + .exp_string("Choose a person to contact now:") + .unwrap(); + // down to choose bob + locked.send_line("j").unwrap(); + + locked + .exp_string("Select code provided by greeter:") + .unwrap(); + }); + greeter.await.unwrap(); + p_greeter + .lock() + .unwrap() + .exp_string("Code to provide to claimer:") + .unwrap(); + let (_, matched) = p_greeter.lock().unwrap().exp_regex("[A-Z0-9]{4}").unwrap(); + let sas_code = dbg!(matched[matched.len() - 4..].to_string()); // last 4 chars are the sas code + + // code selection + + claimer.await.unwrap(); + let cloned_claimer = p_claimer.clone(); + { + let mut locked = cloned_claimer.lock().unwrap(); + + match_sas_code!(locked, sas_code); + } + + // retrieve claimer code + let greeter_cloned = p_greeter.clone(); + let greeter = tokio::task::spawn(async move { + let mut locked = greeter_cloned.lock().unwrap(); + locked.exp_string("Waiting for claimer").unwrap(); + locked + .exp_string("Select code provided by claimer:") + .unwrap(); + }); + + let sas_code = { + let mut locked = p_claimer.lock().unwrap(); + + locked.exp_string("Code to provide to greeter:").unwrap(); + let (_, matched) = locked.exp_regex("[A-Z0-9]{4}").unwrap(); + dbg!(matched[matched.len() - 4..].to_string()) // last 4 chars are the sas code + }; + greeter.await.unwrap(); + + { + let mut locked = p_greeter.lock().unwrap(); + + match_sas_code!(locked, sas_code); + } + let mut greeter = Arc::>::try_unwrap(p_greeter) + .ok() + .unwrap() + .into_inner() + .unwrap(); + greeter.exp_eof().unwrap(); + greeter.process.exit().unwrap(); + drop(greeter); + // device creation + let mut locked = p_claimer.lock().unwrap(); + locked.exp_string("Enter device label:").unwrap(); + locked.send_line("label").unwrap(); + locked.exp_string("Recovering device").unwrap(); + + locked + .exp_string("Enter password for the new device:") + .unwrap(); + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_string("Confirm password:").unwrap(); + + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_eof().unwrap(); +} diff --git a/cli/tests/integration/mod.rs b/cli/tests/integration/mod.rs index b532f33b8e4..a611cb1c7d0 100644 --- a/cli/tests/integration/mod.rs +++ b/cli/tests/integration/mod.rs @@ -16,10 +16,15 @@ use std::{ path::{Path, PathBuf}, }; +use crate::utils::*; +use libparsec::LocalDevice; use libparsec::{ ClientConfig, OrganizationID, ParsecAddr, TmpPath, PARSEC_BASE_CONFIG_DIR, PARSEC_BASE_DATA_DIR, PARSEC_BASE_HOME_DIR, }; +use std::sync::Arc; + +use crate::testenv_utils::DEFAULT_DEVICE_PASSWORD; use crate::testenv_utils::{ initialize_test_organization, new_environment, parsec_addr_from_http_url, TestOrganization, @@ -140,3 +145,70 @@ macro_rules! assert_cmd_failure { .failure() } } + +/// For Alice, with Bob and Toto as recipients +fn shared_recovery_create( + alice: &Arc, + bob: &Arc, + toto: Option<&Arc>, +) { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "info", + "--device", + &alice.device_id.hex() + ) + .stdout(predicates::str::contains(format!( + "Shared recovery {RED}never setup{RESET}" + ))); + + if let Some(toto) = toto { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "create", + "--device", + &alice.device_id.hex(), + "--recipients", + &bob.human_handle.email(), + &toto.human_handle.email(), + "--weights", + "1", + "1", + "--threshold", + "1" + ) + .stdout(predicates::str::contains( + "Shared recovery setup has been created", + )); + } else { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "create", + "--device", + &alice.device_id.hex(), + "--recipients", + &bob.human_handle.email(), + "--weights", + "1", + "--threshold", + "1" + ) + .stdout(predicates::str::contains( + "Shared recovery setup has been created", + )); + } + + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "info", + "--device", + &alice.device_id.hex() + ) + .stdout(predicates::str::contains(format!( + "Shared recovery {GREEN}set up{RESET}" + ))); +} diff --git a/cli/tests/integration/shared_recovery/delete.rs b/cli/tests/integration/shared_recovery/delete.rs index 1dbeee8d66f..72384e2cb82 100644 --- a/cli/tests/integration/shared_recovery/delete.rs +++ b/cli/tests/integration/shared_recovery/delete.rs @@ -1,6 +1,6 @@ use libparsec::{tmp_path, TmpPath}; -use crate::integration_tests::shared_recovery::shared_recovery_create; +use crate::integration_tests::shared_recovery_create; use crate::testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}; use crate::utils::*; @@ -17,7 +17,7 @@ async fn remove_shared_recovery_ok(tmp_path: TmpPath) { _, ) = bootstrap_cli_test(&tmp_path).await.unwrap(); - shared_recovery_create(&alice, &bob, &toto); + shared_recovery_create(&alice, &bob, Some(&toto)); crate::assert_cmd_success!( with_password = DEFAULT_DEVICE_PASSWORD, diff --git a/cli/tests/integration/shared_recovery/list.rs b/cli/tests/integration/shared_recovery/list.rs index 463eb1691db..71cf0583f1f 100644 --- a/cli/tests/integration/shared_recovery/list.rs +++ b/cli/tests/integration/shared_recovery/list.rs @@ -1,6 +1,6 @@ use libparsec::{tmp_path, TmpPath}; -use crate::integration_tests::shared_recovery::shared_recovery_create; +use crate::integration_tests::shared_recovery_create; use crate::testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}; use crate::utils::*; @@ -26,7 +26,7 @@ async fn list_shared_recovery_ok(tmp_path: TmpPath) { ) .stdout(predicates::str::contains("No shared recovery found")); - shared_recovery_create(&alice, &bob, &toto); + shared_recovery_create(&alice, &bob, Some(&toto)); crate::assert_cmd_success!( with_password = DEFAULT_DEVICE_PASSWORD, diff --git a/cli/tests/integration/shared_recovery/mod.rs b/cli/tests/integration/shared_recovery/mod.rs index 82ba9b4d15e..b6ce6518194 100644 --- a/cli/tests/integration/shared_recovery/mod.rs +++ b/cli/tests/integration/shared_recovery/mod.rs @@ -1,56 +1,4 @@ -use crate::utils::*; -use libparsec::LocalDevice; -use std::sync::Arc; - -use crate::testenv_utils::DEFAULT_DEVICE_PASSWORD; - mod create; mod delete; mod info; mod list; - -fn shared_recovery_create( - alice: &Arc, - bob: &Arc, - toto: &Arc, -) { - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "info", - "--device", - &alice.device_id.hex() - ) - .stdout(predicates::str::contains(format!( - "Shared recovery {RED}never setup{RESET}" - ))); - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "create", - "--device", - &alice.device_id.hex(), - "--recipients", - &bob.human_handle.email(), - &toto.human_handle.email(), - "--weights", - "1", - "1", - "--threshold", - "1" - ) - .stdout(predicates::str::contains( - "Shared recovery setup has been created", - )); - - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "info", - "--device", - &alice.device_id.hex() - ) - .stdout(predicates::str::contains(format!( - "Shared recovery {GREEN}set up{RESET}" - ))); -} diff --git a/libparsec/crates/client/src/invite/claimer.rs b/libparsec/crates/client/src/invite/claimer.rs index d50c5536a9e..0aba7ee0e2f 100644 --- a/libparsec/crates/client/src/invite/claimer.rs +++ b/libparsec/crates/client/src/invite/claimer.rs @@ -356,7 +356,7 @@ impl ShamirRecoveryClaimPickRecipientCtx { self.shamir_recovery_created_on } - pub fn yet_to_contact_recipients(&self) -> Vec<&ShamirRecoveryRecipient> { + pub fn recipients_without_a_share(&self) -> Vec<&ShamirRecoveryRecipient> { self.recipients .iter() .filter(|r| !self.shares.contains_key(&r.user_id))