diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 9eff59f0e15..f9988768fa7 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -63,7 +63,7 @@ jobs: TESTBED_SERVER: http://localhost:6777 services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20041.677dcc5 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20049.b16ce69 ports: - 6777:6777 steps: diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 1e9fed1b3e8..059d29a1fd8 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -36,7 +36,7 @@ jobs: # https://github.com/Scille/parsec-cloud/pkgs/container/parsec-cloud%2Fparsec-testbed-server services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20041.677dcc5 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20049.b16ce69 ports: - 6777:6777 steps: diff --git a/bindings/electron/src/index.d.ts b/bindings/electron/src/index.d.ts index c369031e2f9..fd7027417a0 100644 --- a/bindings/electron/src/index.d.ts +++ b/bindings/electron/src/index.d.ts @@ -1325,6 +1325,14 @@ export interface InviteListItemDevice { created_on: number status: InvitationStatus } +export interface InviteListItemShamirRecovery { + tag: "ShamirRecovery" + addr: string + token: string + created_on: number + claimer_user_id: string + status: InvitationStatus +} export interface InviteListItemUser { tag: "User" addr: string @@ -1335,6 +1343,7 @@ export interface InviteListItemUser { } export type InviteListItem = | InviteListItemDevice + | InviteListItemShamirRecovery | InviteListItemUser @@ -1421,6 +1430,14 @@ export interface ParsedParsecAddrInvitationDevice { organization_id: string token: string } +export interface ParsedParsecAddrInvitationShamirRecovery { + tag: "InvitationShamirRecovery" + hostname: string + port: number + use_ssl: boolean + organization_id: string + token: string +} export interface ParsedParsecAddrInvitationUser { tag: "InvitationUser" hostname: string @@ -1469,6 +1486,7 @@ export interface ParsedParsecAddrWorkspacePath { } export type ParsedParsecAddr = | ParsedParsecAddrInvitationDevice + | ParsedParsecAddrInvitationShamirRecovery | ParsedParsecAddrInvitationUser | ParsedParsecAddrOrganization | ParsedParsecAddrOrganizationBootstrap diff --git a/bindings/electron/src/meths.rs b/bindings/electron/src/meths.rs index 62b67d84064..e7b23c06125 100644 --- a/bindings/electron/src/meths.rs +++ b/bindings/electron/src/meths.rs @@ -5468,6 +5468,74 @@ fn variant_invite_list_item_js_to_rs<'a>( status, }) } + "InviteListItemShamirRecovery" => { + let addr = { + let js_val: Handle = obj.get(cx, "addr")?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + libparsec::ParsecInvitationAddr::from_any(&s).map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let token = { + let js_val: Handle = obj.get(cx, "token")?; + { + let custom_from_rs_string = + |s: String| -> Result { + libparsec::InvitationToken::from_hex(s.as_str()) + .map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let created_on = { + let js_val: Handle = obj.get(cx, "createdOn")?; + { + let v = js_val.value(cx); + let custom_from_rs_f64 = |n: f64| -> Result<_, &'static str> { + libparsec::DateTime::from_timestamp_micros((n * 1_000_000f64) as i64) + .map_err(|_| "Out-of-bound datetime") + }; + match custom_from_rs_f64(v) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let claimer_user_id = { + let js_val: Handle = obj.get(cx, "claimerUserId")?; + { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let status = { + let js_val: Handle = obj.get(cx, "status")?; + { + let js_string = js_val.value(cx); + enum_invitation_status_js_to_rs(cx, js_string.as_str())? + } + }; + Ok(libparsec::InviteListItem::ShamirRecovery { + addr, + token, + created_on, + claimer_user_id, + status, + }) + } "InviteListItemUser" => { let addr = { let js_val: Handle = obj.get(cx, "addr")?; @@ -5584,6 +5652,62 @@ fn variant_invite_list_item_rs_to_js<'a>( JsString::try_new(cx, enum_invitation_status_rs_to_js(status)).or_throw(cx)?; js_obj.set(cx, "status", js_status)?; } + libparsec::InviteListItem::ShamirRecovery { + addr, + token, + created_on, + claimer_user_id, + status, + .. + } => { + let js_tag = JsString::try_new(cx, "InviteListItemShamirRecovery").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_addr = JsString::try_new(cx, { + let custom_to_rs_string = + |addr: libparsec::ParsecInvitationAddr| -> Result { + Ok(addr.to_url().into()) + }; + match custom_to_rs_string(addr) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "addr", js_addr)?; + let js_token = JsString::try_new(cx, { + let custom_to_rs_string = + |x: libparsec::InvitationToken| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(token) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "token", js_token)?; + let js_created_on = JsNumber::new(cx, { + let custom_to_rs_f64 = |dt: libparsec::DateTime| -> Result { + Ok((dt.as_timestamp_micros() as f64) / 1_000_000f64) + }; + match custom_to_rs_f64(created_on) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }); + js_obj.set(cx, "createdOn", js_created_on)?; + let js_claimer_user_id = JsString::try_new(cx, { + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(claimer_user_id) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "claimerUserId", js_claimer_user_id)?; + let js_status = + JsString::try_new(cx, enum_invitation_status_rs_to_js(status)).or_throw(cx)?; + js_obj.set(cx, "status", js_status)?; + } libparsec::InviteListItem::User { addr, token, @@ -5883,6 +6007,60 @@ fn variant_parsed_parsec_addr_js_to_rs<'a>( token, }) } + "ParsedParsecAddrInvitationShamirRecovery" => { + let hostname = { + let js_val: Handle = obj.get(cx, "hostname")?; + js_val.value(cx) + }; + let port = { + let js_val: Handle = obj.get(cx, "port")?; + { + let v = js_val.value(cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let use_ssl = { + let js_val: Handle = obj.get(cx, "useSsl")?; + js_val.value(cx) + }; + let organization_id = { + let js_val: Handle = obj.get(cx, "organizationId")?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + libparsec::OrganizationID::try_from(s.as_str()).map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let token = { + let js_val: Handle = obj.get(cx, "token")?; + { + let custom_from_rs_string = + |s: String| -> Result { + libparsec::InvitationToken::from_hex(s.as_str()) + .map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + Ok(libparsec::ParsedParsecAddr::InvitationShamirRecovery { + hostname, + port, + use_ssl, + organization_id, + token, + }) + } "ParsedParsecAddrInvitationUser" => { let hostname = { let js_val: Handle = obj.get(cx, "hostname")?; @@ -6201,6 +6379,36 @@ fn variant_parsed_parsec_addr_rs_to_js<'a>( .or_throw(cx)?; js_obj.set(cx, "token", js_token)?; } + libparsec::ParsedParsecAddr::InvitationShamirRecovery { + hostname, + port, + use_ssl, + organization_id, + token, + .. + } => { + let js_tag = + JsString::try_new(cx, "ParsedParsecAddrInvitationShamirRecovery").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_hostname = JsString::try_new(cx, hostname).or_throw(cx)?; + js_obj.set(cx, "hostname", js_hostname)?; + let js_port = JsNumber::new(cx, port as f64); + js_obj.set(cx, "port", js_port)?; + let js_use_ssl = JsBoolean::new(cx, use_ssl); + js_obj.set(cx, "useSsl", js_use_ssl)?; + let js_organization_id = JsString::try_new(cx, organization_id).or_throw(cx)?; + js_obj.set(cx, "organizationId", js_organization_id)?; + let js_token = JsString::try_new(cx, { + let custom_to_rs_string = + |x: libparsec::InvitationToken| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(token) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "token", js_token)?; + } libparsec::ParsedParsecAddr::InvitationUser { hostname, port, diff --git a/bindings/generator/api/addr.py b/bindings/generator/api/addr.py index 998f51ef826..155fd797db3 100644 --- a/bindings/generator/api/addr.py +++ b/bindings/generator/api/addr.py @@ -95,6 +95,13 @@ class InvitationDevice: organization_id: OrganizationID token: InvitationToken + class InvitationShamirRecovery: + hostname: str + port: U32 + use_ssl: bool + organization_id: OrganizationID + token: InvitationToken + class PkiEnrollment: hostname: str port: U32 diff --git a/bindings/generator/api/invite.py b/bindings/generator/api/invite.py index 868e1b3d90c..c77a26a5f24 100644 --- a/bindings/generator/api/invite.py +++ b/bindings/generator/api/invite.py @@ -358,6 +358,30 @@ async def client_new_device_invitation( raise NotImplementedError +class ClientNewShamirRecoveryInvitationError(ErrorVariant): + class Offline: + pass + + class NotAllowed: + pass + + class UserNotFound: + pass + + class Internal: + pass + + +async def client_new_shamir_recovery_invitation( + client: Handle, + send_email: bool, +) -> Result[ + NewInvitationInfo, + ClientNewShamirRecoveryInvitationError, +]: + raise NotImplementedError + + class ClientCancelInvitationError(ErrorVariant): class Offline: pass @@ -393,6 +417,13 @@ class Device: created_on: DateTime status: InvitationStatus + class ShamirRecovery: + addr: ParsecInvitationAddr + token: InvitationToken + created_on: DateTime + claimer_user_id: UserID + status: InvitationStatus + class ListInvitationsError(ErrorVariant): class Offline: diff --git a/bindings/web/src/meths.rs b/bindings/web/src/meths.rs index 5abf0d93adf..d3dfa9aa552 100644 --- a/bindings/web/src/meths.rs +++ b/bindings/web/src/meths.rs @@ -6035,6 +6035,85 @@ fn variant_invite_list_item_js_to_rs(obj: JsValue) -> Result { + let addr = { + let js_val = Reflect::get(&obj, &"addr".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result<_, String> { + libparsec::ParsecInvitationAddr::from_any(&s).map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid ParsecInvitationAddr"))? + }; + let token = { + let js_val = Reflect::get(&obj, &"token".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = + |s: String| -> Result { + libparsec::InvitationToken::from_hex(s.as_str()) + .map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid InvitationToken"))? + }; + let created_on = { + let js_val = Reflect::get(&obj, &"createdOn".into())?; + { + let v = js_val.dyn_into::()?.value_of(); + let custom_from_rs_f64 = |n: f64| -> Result<_, &'static str> { + libparsec::DateTime::from_timestamp_micros((n * 1_000_000f64) as i64) + .map_err(|_| "Out-of-bound datetime") + }; + let v = custom_from_rs_f64(v).map_err(|e| TypeError::new(e.as_ref()))?; + v + } + }; + let claimer_user_id = { + let js_val = Reflect::get(&obj, &"claimerUserId".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid UserID"))? + }; + let status = { + let js_val = Reflect::get(&obj, &"status".into())?; + { + let raw_string = js_val.as_string().ok_or_else(|| { + let type_error = TypeError::new("value is not a string"); + type_error.set_cause(&js_val); + JsValue::from(type_error) + })?; + enum_invitation_status_js_to_rs(raw_string.as_str()) + }? + }; + Ok(libparsec::InviteListItem::ShamirRecovery { + addr, + token, + created_on, + claimer_user_id, + status, + }) + } "InviteListItemUser" => { let addr = { let js_val = Reflect::get(&obj, &"addr".into())?; @@ -6163,6 +6242,65 @@ fn variant_invite_list_item_rs_to_js( let js_status = JsValue::from_str(enum_invitation_status_rs_to_js(status)); Reflect::set(&js_obj, &"status".into(), &js_status)?; } + libparsec::InviteListItem::ShamirRecovery { + addr, + token, + created_on, + claimer_user_id, + status, + .. + } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"InviteListItemShamirRecovery".into(), + )?; + let js_addr = JsValue::from_str({ + let custom_to_rs_string = + |addr: libparsec::ParsecInvitationAddr| -> Result { + Ok(addr.to_url().into()) + }; + match custom_to_rs_string(addr) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"addr".into(), &js_addr)?; + let js_token = JsValue::from_str({ + let custom_to_rs_string = + |x: libparsec::InvitationToken| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(token) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"token".into(), &js_token)?; + let js_created_on = { + let custom_to_rs_f64 = |dt: libparsec::DateTime| -> Result { + Ok((dt.as_timestamp_micros() as f64) / 1_000_000f64) + }; + let v = match custom_to_rs_f64(created_on) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + }; + JsValue::from(v) + }; + Reflect::set(&js_obj, &"createdOn".into(), &js_created_on)?; + let js_claimer_user_id = JsValue::from_str({ + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(claimer_user_id) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"claimerUserId".into(), &js_claimer_user_id)?; + let js_status = JsValue::from_str(enum_invitation_status_rs_to_js(status)); + Reflect::set(&js_obj, &"status".into(), &js_status)?; + } libparsec::InviteListItem::User { addr, token, @@ -6494,6 +6632,76 @@ fn variant_parsed_parsec_addr_js_to_rs( token, }) } + "ParsedParsecAddrInvitationShamirRecovery" => { + let hostname = { + let js_val = Reflect::get(&obj, &"hostname".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string"))? + }; + let port = { + let js_val = Reflect::get(&obj, &"port".into())?; + { + let v = js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not a number"))? + .value_of(); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + return Err(JsValue::from(TypeError::new("Not an u32 number"))); + } + v as u32 + } + }; + let use_ssl = { + let js_val = Reflect::get(&obj, &"useSsl".into())?; + js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not a boolean"))? + .value_of() + }; + let organization_id = { + let js_val = Reflect::get(&obj, &"organizationId".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result<_, String> { + libparsec::OrganizationID::try_from(s.as_str()) + .map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid OrganizationID"))? + }; + let token = { + let js_val = Reflect::get(&obj, &"token".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = + |s: String| -> Result { + libparsec::InvitationToken::from_hex(s.as_str()) + .map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid InvitationToken"))? + }; + Ok(libparsec::ParsedParsecAddr::InvitationShamirRecovery { + hostname, + port, + use_ssl, + organization_id, + token, + }) + } "ParsedParsecAddrInvitationUser" => { let hostname = { let js_val = Reflect::get(&obj, &"hostname".into())?; @@ -6904,6 +7112,38 @@ fn variant_parsed_parsec_addr_rs_to_js( }); Reflect::set(&js_obj, &"token".into(), &js_token)?; } + libparsec::ParsedParsecAddr::InvitationShamirRecovery { + hostname, + port, + use_ssl, + organization_id, + token, + .. + } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"ParsedParsecAddrInvitationShamirRecovery".into(), + )?; + let js_hostname = hostname.into(); + Reflect::set(&js_obj, &"hostname".into(), &js_hostname)?; + let js_port = JsValue::from(port); + Reflect::set(&js_obj, &"port".into(), &js_port)?; + let js_use_ssl = use_ssl.into(); + Reflect::set(&js_obj, &"useSsl".into(), &js_use_ssl)?; + let js_organization_id = JsValue::from_str(organization_id.as_ref()); + Reflect::set(&js_obj, &"organizationId".into(), &js_organization_id)?; + let js_token = JsValue::from_str({ + let custom_to_rs_string = + |x: libparsec::InvitationToken| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(token) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"token".into(), &js_token)?; + } libparsec::ParsedParsecAddr::InvitationUser { hostname, port, diff --git a/cli/src/commands/invite/claim.rs b/cli/src/commands/invite/claim.rs index dba8cacde48..82275fe8536 100644 --- a/cli/src/commands/invite/claim.rs +++ b/cli/src/commands/invite/claim.rs @@ -16,7 +16,8 @@ use crate::utils::*; #[derive(clap::Parser)] pub struct Args { - /// Server invitation address (e.g.: parsec3://127.0.0.1:41905/Org?no_ssl=true&action=claim_user&token=4e45cc21e7604af196173ff6c9184a1f) + // cspell:disable-next-line + /// Server invitation address (e.g.: parsec3://127.0.0.1:41997/Org?no_ssl=true&a=claim_shamir_recovery&p=xBA2FaaizwKy4qG5cGDFlXaL`) #[arg(short, long)] addr: ParsecInvitationAddr, /// Read the password from stdin instead of a TTY. diff --git a/cli/src/commands/invite/greet.rs b/cli/src/commands/invite/greet.rs index 7c0f432ce44..d9af5c0cd2d 100644 --- a/cli/src/commands/invite/greet.rs +++ b/cli/src/commands/invite/greet.rs @@ -61,6 +61,10 @@ pub async fn main(args: Args) -> anyhow::Result<()> { let ctx = step4_device(ctx).await?; step5_device(ctx).await } + InviteListItem::ShamirRecovery { .. } => { + // TODO: https://github.com/Scille/parsec-cloud/issues/8841 + Err(anyhow::anyhow!("Shamir recovery greeting not implemented")) + } } } diff --git a/cli/src/commands/invite/list.rs b/cli/src/commands/invite/list.rs index bb802f3eba1..d5a63175d11 100644 --- a/cli/src/commands/invite/list.rs +++ b/cli/src/commands/invite/list.rs @@ -1,9 +1,6 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -use libparsec::{ - authenticated_cmds::latest::invite_list::{self, InviteListItem, InviteListRep}, - InvitationStatus, -}; +use libparsec::{authenticated_cmds::latest::invite_list::InviteListItem, InvitationStatus}; use crate::utils::*; @@ -24,23 +21,17 @@ pub async fn main(args: Args) -> anyhow::Result<()> { device.as_deref().unwrap_or("N/A") ); - let (cmds, _) = load_cmds(&config_dir, device, password_stdin).await?; + let client = load_client(&config_dir, device, password_stdin).await?; let mut handle = start_spinner("Listing invitations".into()); - let rep = cmds.send(invite_list::Req).await?; + let invitations = client.list_invitations().await?; - let invitations = match rep { - InviteListRep::Ok { invitations } => invitations, - rep => { - return Err(anyhow::anyhow!( - "Server error while listing invitations: {rep:?}" - )); - } - }; + let users = client.list_users(false, None, None).await?; if invitations.is_empty() { handle.stop_with_message("No invitation.".into()); } else { + handle.stop_with_message(format!("{} invitations found.", invitations.len())); for invitation in invitations { let (token, status, display_type) = match invitation { InviteListItem::User { @@ -50,6 +41,23 @@ pub async fn main(args: Args) -> anyhow::Result<()> { .. } => (token, status, format!("user (email={claimer_email}")), InviteListItem::Device { status, token, .. } => (token, status, "device".into()), + InviteListItem::ShamirRecovery { + status, + token, + claimer_user_id, + .. + } => { + let claimer_human_handle = users + .iter() + .find(|user| user.id == claimer_user_id) + .map(|user| format!("{}", user.human_handle)) + .unwrap_or("N/A".to_string()); + ( + token, + status, + format!("shamir recovery ({claimer_human_handle})"), + ) + } }; let token = token.hex(); @@ -61,7 +69,7 @@ pub async fn main(args: Args) -> anyhow::Result<()> { InvitationStatus::Finished => format!("{GREEN}finished{RESET}"), }; - handle.stop_with_message(format!("{token}\t{display_status}\t{display_type}")) + println!("{token}\t{display_status}\t{display_type}"); } } diff --git a/cli/src/commands/invite/mod.rs b/cli/src/commands/invite/mod.rs index 45df4618025..75c999198e8 100644 --- a/cli/src/commands/invite/mod.rs +++ b/cli/src/commands/invite/mod.rs @@ -3,6 +3,7 @@ mod claim; mod device; mod greet; mod list; +mod shared_recovery; mod user; #[derive(clap::Subcommand)] @@ -19,6 +20,8 @@ pub enum Group { User(user::Args), /// Create device invitation Device(device::Args), + /// Create shared recovery invitation + SharedRecovery(shared_recovery::Args), } pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { @@ -29,5 +32,6 @@ pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { Group::List(args) => list::main(args).await, Group::User(args) => user::main(args).await, Group::Device(args) => device::main(args).await, + Group::SharedRecovery(args) => shared_recovery::main(args).await, } } diff --git a/cli/src/commands/invite/shared_recovery.rs b/cli/src/commands/invite/shared_recovery.rs new file mode 100644 index 00000000000..b0fdf419ccd --- /dev/null +++ b/cli/src/commands/invite/shared_recovery.rs @@ -0,0 +1,91 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec::{InvitationEmailSentStatus, InvitationType, ParsecInvitationAddr}; + +use crate::utils::*; + +crate::clap_parser_with_shared_opts_builder!( + #[with = config_dir, device, password_stdin] + pub struct Args { + /// Claimer email (i.e.: The invitee) + #[arg(long)] + email: String, + /// Send email to the invitee + #[arg(long, default_value_t)] + send_email: bool, + } +); + +pub async fn main(args: Args) -> anyhow::Result<()> { + let Args { + email, + send_email, + device, + config_dir, + password_stdin, + } = args; + log::trace!( + "Inviting an user to perform a shared recovery (confdir={}, device={})", + config_dir.display(), + device.as_deref().unwrap_or("N/A") + ); + + let client = load_client(&config_dir, device, password_stdin).await?; + + { + let _spinner = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + } + + let users = client.list_users(true, None, None).await?; + let user_info = users + .iter() + .find(|u| u.human_handle.email() == email) + .ok_or_else(|| anyhow::anyhow!("User with email {} not found", email))?; + + let mut handle = start_spinner("Creating a shared recovery invitation".into()); + let (url, email_sent_status) = match client + .new_shamir_recovery_invitation(user_info.id, send_email) + .await + { + Ok((token, email_sent_status)) => ( + ParsecInvitationAddr::new( + client.organization_addr().clone(), + client.organization_id().clone(), + InvitationType::ShamirRecovery, + token, + ) + .to_url(), + email_sent_status, + ), + Err(e) => { + return Err(anyhow::anyhow!( + "Server refused to create shared recovery invitation: {e}" + )); + } + }; + + handle.stop_with_message(format!("Invitation URL: {YELLOW}{url}{RESET}")); + + if send_email { + match email_sent_status { + InvitationEmailSentStatus::Success => { + println!("Invitation email sent to {}", email); + } + InvitationEmailSentStatus::RecipientRefused => { + println!( + "Invitation email not sent to {} because the recipient was refused", + email + ); + } + InvitationEmailSentStatus::ServerUnavailable => { + println!( + "Invitation email not sent to {} because the server is unavailable", + email + ); + } + } + } + + Ok(()) +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index dbeb2563cf6..6f9de7c82a6 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -6,7 +6,7 @@ pub mod rm; #[cfg(feature = "testenv")] pub mod run_testenv; pub mod server; -pub mod shamir_setup; +pub mod shared_recovery; pub mod tos; pub mod user; pub mod workspace; diff --git a/cli/src/commands/shamir_setup.rs b/cli/src/commands/shared_recovery/create.rs similarity index 86% rename from cli/src/commands/shamir_setup.rs rename to cli/src/commands/shared_recovery/create.rs index 85541553869..2f4a01fef83 100644 --- a/cli/src/commands/shamir_setup.rs +++ b/cli/src/commands/shared_recovery/create.rs @@ -6,7 +6,7 @@ use crate::utils::{load_client, start_spinner}; crate::clap_parser_with_shared_opts_builder!( #[with = config_dir, device, password_stdin] - pub struct ShamirSetupCreate { + pub struct Args { /// Share recipients, if missing organization's admins will be used instead /// Author must not be included as recipient. /// User email is expected @@ -24,8 +24,8 @@ crate::clap_parser_with_shared_opts_builder!( } ); -pub async fn shamir_setup_create(shamir_setup: ShamirSetupCreate) -> anyhow::Result<()> { - let ShamirSetupCreate { +pub async fn main(shamir_setup: Args) -> anyhow::Result<()> { + let Args { recipients, weights, threshold, @@ -35,8 +35,13 @@ pub async fn shamir_setup_create(shamir_setup: ShamirSetupCreate) -> anyhow::Res } = shamir_setup; let client = load_client(&config_dir, device, password_stdin).await?; - let mut handle = start_spinner("Creating shamir setup".into()); + { + let _spinner = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + } + + let mut handle = start_spinner("Creating shamir setup".into()); let users = client.list_users(true, None, None).await?; let recipients_ids: Vec<_> = if let Some(recipients) = recipients { let recipient_info: HashMap<_, _> = users @@ -45,7 +50,7 @@ pub async fn shamir_setup_create(shamir_setup: ShamirSetupCreate) -> anyhow::Res .map(|info| (info.human_handle.email().to_owned(), info.id)) .collect(); if recipient_info.len() != recipients.len() { - handle.stop_with_message("A user in missing".into()); + handle.stop_with_message("A user is missing".into()); client.stop().await; return Ok(()); } @@ -56,7 +61,9 @@ pub async fn shamir_setup_create(shamir_setup: ShamirSetupCreate) -> anyhow::Res } else { users .iter() - .filter(|info| info.current_profile == UserProfile::Admin) + .filter(|info| { + info.current_profile == UserProfile::Admin && info.id != client.user_id() + }) .map(|info| info.id) .collect() }; diff --git a/cli/src/commands/shared_recovery/mod.rs b/cli/src/commands/shared_recovery/mod.rs new file mode 100644 index 00000000000..655b20c0a28 --- /dev/null +++ b/cli/src/commands/shared_recovery/mod.rs @@ -0,0 +1,13 @@ +pub mod create; + +#[derive(clap::Subcommand)] +pub enum Group { + /// Export recovery device + Create(create::Args), +} + +pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { + match command { + Group::Create(args) => create::main(args).await, + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 773e4947f4a..a0a33f54e6f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -38,8 +38,6 @@ enum Command { /// This command creates three users, `Alice`, `Bob` and `Toto`, /// To run testenv, see the script run_testenv in the current directory. RunTestenv(run_testenv::RunTestenv), - /// Create a shamir setup - ShamirSetupCreate(shamir_setup::ShamirSetupCreate), /// List files in a workspace Ls(ls::Ls), /// Remove a file from a workspace @@ -47,6 +45,9 @@ enum Command { /// Contains subcommands related to Term of Service (TOS). #[command(subcommand)] Tos(tos::Group), + /// Contains subcommands related to shared recovery devices (shamir) + #[command(subcommand)] + SharedRecovery(shared_recovery::Group), } #[tokio::main] @@ -63,11 +64,11 @@ async fn main() -> anyhow::Result<()> { Command::Workspace(workspace) => workspace::dispatch_command(workspace).await, #[cfg(feature = "testenv")] Command::RunTestenv(run_testenv) => run_testenv::run_testenv(run_testenv).await, - Command::ShamirSetupCreate(shamir_setup_create) => { - shamir_setup::shamir_setup_create(shamir_setup_create).await - } Command::Ls(ls) => ls::ls(ls).await, Command::Rm(rm) => rm::rm(rm).await, Command::Tos(tos) => tos::dispatch_command(tos).await, + Command::SharedRecovery(shared_recovery) => { + shared_recovery::dispatch_command(shared_recovery).await + } } } diff --git a/client/src/parsec/invitation.ts b/client/src/parsec/invitation.ts index 79f0e5b1aef..755d262a392 100644 --- a/client/src/parsec/invitation.ts +++ b/client/src/parsec/invitation.ts @@ -70,7 +70,7 @@ export async function listUserInvitations(options?: { includeFinished?: boolean; }): Promise, ListInvitationsError>> { function shouldIncludeInvitation(invite: InviteListItem): boolean { - if (invite.tag === InviteListItemTag.Device) { + if (invite.tag !== InviteListItemTag.User) { return false; } if (invite.status === InvitationStatus.Cancelled && (!options || !options.includeCancelled)) { diff --git a/client/src/plugins/libparsec/definitions.ts b/client/src/plugins/libparsec/definitions.ts index 3cbc9d1df0c..dcbcc466da5 100644 --- a/client/src/plugins/libparsec/definitions.ts +++ b/client/src/plugins/libparsec/definitions.ts @@ -1547,6 +1547,7 @@ export type ImportRecoveryDeviceError = // InviteListItem export enum InviteListItemTag { Device = 'InviteListItemDevice', + ShamirRecovery = 'InviteListItemShamirRecovery', User = 'InviteListItemUser', } @@ -1557,6 +1558,14 @@ export interface InviteListItemDevice { createdOn: DateTime status: InvitationStatus } +export interface InviteListItemShamirRecovery { + tag: InviteListItemTag.ShamirRecovery + addr: ParsecInvitationAddr + token: InvitationToken + createdOn: DateTime + claimerUserId: UserID + status: InvitationStatus +} export interface InviteListItemUser { tag: InviteListItemTag.User addr: ParsecInvitationAddr @@ -1567,6 +1576,7 @@ export interface InviteListItemUser { } export type InviteListItem = | InviteListItemDevice + | InviteListItemShamirRecovery | InviteListItemUser // ListInvitationsError @@ -1669,6 +1679,7 @@ export type ParseParsecAddrError = // ParsedParsecAddr export enum ParsedParsecAddrTag { InvitationDevice = 'ParsedParsecAddrInvitationDevice', + InvitationShamirRecovery = 'ParsedParsecAddrInvitationShamirRecovery', InvitationUser = 'ParsedParsecAddrInvitationUser', Organization = 'ParsedParsecAddrOrganization', OrganizationBootstrap = 'ParsedParsecAddrOrganizationBootstrap', @@ -1685,6 +1696,14 @@ export interface ParsedParsecAddrInvitationDevice { organizationId: OrganizationID token: InvitationToken } +export interface ParsedParsecAddrInvitationShamirRecovery { + tag: ParsedParsecAddrTag.InvitationShamirRecovery + hostname: string + port: U32 + useSsl: boolean + organizationId: OrganizationID + token: InvitationToken +} export interface ParsedParsecAddrInvitationUser { tag: ParsedParsecAddrTag.InvitationUser hostname: string @@ -1733,6 +1752,7 @@ export interface ParsedParsecAddrWorkspacePath { } export type ParsedParsecAddr = | ParsedParsecAddrInvitationDevice + | ParsedParsecAddrInvitationShamirRecovery | ParsedParsecAddrInvitationUser | ParsedParsecAddrOrganization | ParsedParsecAddrOrganizationBootstrap diff --git a/libparsec/crates/client/src/client/mod.rs b/libparsec/crates/client/src/client/mod.rs index 99a27cbc55d..150b855d236 100644 --- a/libparsec/crates/client/src/client/mod.rs +++ b/libparsec/crates/client/src/client/mod.rs @@ -67,6 +67,7 @@ pub use crate::invite::{ CancelInvitationError as ClientCancelInvitationError, DeviceGreetInitialCtx, InvitationEmailSentStatus, InviteListItem, ListInvitationsError as ClientListInvitationsError, NewDeviceInvitationError as ClientNewDeviceInvitationError, + NewShamirRecoveryInvitationError as ClientNewShamirRecoveryInvitationError, NewUserInvitationError as ClientNewUserInvitationError, UserGreetInitialCtx, }; pub use crate::workspace::WorkspaceOps; @@ -499,6 +500,15 @@ impl Client { crate::invite::new_device_invitation(&self.cmds, send_email).await } + pub async fn new_shamir_recovery_invitation( + &self, + user_id: UserID, + send_email: bool, + ) -> Result<(InvitationToken, InvitationEmailSentStatus), ClientNewShamirRecoveryInvitationError> + { + crate::invite::new_shamir_recovery_invitation(&self.cmds, user_id, send_email).await + } + pub async fn cancel_invitation( &self, token: InvitationToken, diff --git a/libparsec/crates/client/src/invite/claimer.rs b/libparsec/crates/client/src/invite/claimer.rs index e76040a1926..7caa75bb5df 100644 --- a/libparsec/crates/client/src/invite/claimer.rs +++ b/libparsec/crates/client/src/invite/claimer.rs @@ -258,6 +258,18 @@ pub async fn claimer_retrieve_info( time_provider, ), )), + UserOrDevice::ShamirRecovery { + claimer_user_id, + claimer_human_handle, + recipients, + threshold, + } => { + // TODO: https://github.com/Scille/parsec-cloud/issues/8841 + Err(anyhow::anyhow!( + "Shamir recovery greeting not implemented {claimer_user_id:?} {claimer_human_handle:?} {recipients:?} {threshold}" + ) + .into()) + } }, bad_rep @ Rep::UnknownStatus { .. } => { Err(anyhow::anyhow!("Unexpected server response: {:?}", bad_rep).into()) diff --git a/libparsec/crates/client/src/invite/greeter.rs b/libparsec/crates/client/src/invite/greeter.rs index 9123278f737..b1aa07f6c83 100644 --- a/libparsec/crates/client/src/invite/greeter.rs +++ b/libparsec/crates/client/src/invite/greeter.rs @@ -131,6 +131,67 @@ pub async fn new_device_invitation( } } +/* + * new_shamir_invitation + */ + +#[derive(Debug, thiserror::Error)] +pub enum NewShamirRecoveryInvitationError { + #[error("Cannot reach the server")] + Offline, + #[error("Author not part of the user's current recipients")] + NotAllowed, + #[error("Provided user not found")] + UserNotFound, + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl From for NewShamirRecoveryInvitationError { + fn from(value: ConnectionError) -> Self { + match value { + ConnectionError::NoResponse(_) => Self::Offline, + err => Self::Internal(err.into()), + } + } +} + +pub async fn new_shamir_recovery_invitation( + cmds: &AuthenticatedCmds, + claimer_user_id: UserID, + send_email: bool, +) -> Result<(InvitationToken, InvitationEmailSentStatus), NewShamirRecoveryInvitationError> { + use authenticated_cmds::latest::invite_new_shamir_recovery::{ + InvitationEmailSentStatus as ApiInvitationEmailSentStatus, Rep, Req, + }; + + let req = Req { + send_email, + claimer_user_id, + }; + let rep = cmds.send(req).await?; + + match rep { + Rep::Ok { token, email_sent } => { + let email_sent = match email_sent { + ApiInvitationEmailSentStatus::Success => InvitationEmailSentStatus::Success, + ApiInvitationEmailSentStatus::ServerUnavailable => { + InvitationEmailSentStatus::ServerUnavailable + } + ApiInvitationEmailSentStatus::RecipientRefused => { + InvitationEmailSentStatus::RecipientRefused + } + }; + Ok((token, email_sent)) + } + Rep::AuthorNotAllowed => Err(NewShamirRecoveryInvitationError::NotAllowed), + Rep::UserNotFound => Err(NewShamirRecoveryInvitationError::UserNotFound), + rep @ Rep::UnknownStatus { .. } => { + Err(anyhow::anyhow!("Unexpected server response: {:?}", rep).into()) + } + } +} + /* * delete_invitation */ diff --git a/libparsec/crates/client/tests/unit/client/shamir_setup_create.rs b/libparsec/crates/client/tests/unit/client/shamir_setup_create.rs index 1e3122caab7..3237697c9f2 100644 --- a/libparsec/crates/client/tests/unit/client/shamir_setup_create.rs +++ b/libparsec/crates/client/tests/unit/client/shamir_setup_create.rs @@ -1,77 +1,35 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, -}; +use std::collections::HashMap; -use libparsec_client_connection::test_register_sequence_of_send_hooks; -use libparsec_protocol::authenticated_cmds; use libparsec_tests_fixtures::prelude::*; use libparsec_types::prelude::*; use super::utils::client_factory; -#[parsec_test(testbed = "coolorg")] +#[parsec_test(testbed = "coolorg", with_server)] async fn ok(env: &TestbedEnv) { let alice = env.local_device("alice@dev1"); let client = client_factory(&env.discriminant_dir, alice.clone()).await; - // Mock requests to server - let new_shamir_certificates: Arc>> = Arc::default(); - let new_device_certificates: Arc>> = Arc::default(); - - test_register_sequence_of_send_hooks!( - &env.discriminant_dir, - // 1) Create setup and device - { - let new_device_certificates = new_device_certificates.clone(); - let alice = alice.clone(); - move |req: authenticated_cmds::latest::device_create::Req| { - // Ensure the device is of the right type - let new_device = DeviceCertificate::verify_and_load( - &req.device_certificate, - &alice.verify_key(), - CertificateSignerRef::User(&alice.device_id), - None, - ) - .unwrap(); - p_assert_eq!(new_device.purpose, DevicePurpose::ShamirRecovery); - - let mut certs = new_device_certificates.lock().unwrap(); - certs.push(req.device_certificate); - authenticated_cmds::latest::device_create::Rep::Ok - } - }, - { - let new_shamir_certificates = new_shamir_certificates.clone(); - move |req: authenticated_cmds::latest::shamir_recovery_setup::Req| { - let setup = req.setup.unwrap(); - let mut certs = new_shamir_certificates.lock().unwrap(); - certs.push(setup.brief); - // Note we ignore share certificates since they are not for our user - authenticated_cmds::latest::shamir_recovery_setup::Rep::Ok - } - }, - // 2) Fetch new certificates - { - let new_shamir_certificates = new_shamir_certificates.clone(); - let new_device_certificates = new_device_certificates.clone(); - move |_req: authenticated_cmds::latest::certificate_get::Req| { - authenticated_cmds::latest::certificate_get::Rep::Ok { - common_certificates: new_device_certificates.lock().unwrap().clone(), - realm_certificates: HashMap::new(), - sequester_certificates: vec![], - shamir_recovery_certificates: new_shamir_certificates.lock().unwrap().clone(), - } - } - }, - ); - let bob_user_id: UserID = "bob".parse().unwrap(); let share_recipients = HashMap::from([(bob_user_id, 2)]); client .shamir_setup_create(share_recipients, 1) .await .unwrap(); + + // The `shamir_setup_create` already polls the server for new certificates, + // But we do it once more to check its idempotent behavior + client.poll_server_for_new_certificates().await.unwrap(); + + // Also check that Bob can successfully retrieve the new certificates + let bob = env.local_device("bob@dev1"); + let client = client_factory(&env.discriminant_dir, bob.clone()).await; + client.poll_server_for_new_certificates().await.unwrap(); + + // Also check that Mallory is unaffected + let mallory = env.local_device("mallory@dev1"); + let client = client_factory(&env.discriminant_dir, mallory.clone()).await; + client.poll_server_for_new_certificates().await.unwrap(); } diff --git a/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 b/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 index d2a2620de07..20b04837533 100644 --- a/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 +++ b/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 @@ -61,6 +61,28 @@ "type": "InvitationStatus" } ] + }, + { + "name": "ShamirRecovery", + "discriminant_value": "SHAMIR_RECOVERY", + "fields": [ + { + "name": "token", + "type": "InvitationToken" + }, + { + "name": "created_on", + "type": "DateTime" + }, + { + "name": "claimer_user_id", + "type": "UserID" + }, + { + "name": "status", + "type": "InvitationStatus" + } + ] } ] } diff --git a/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 b/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 new file mode 100644 index 00000000000..87fb234637d --- /dev/null +++ b/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 @@ -0,0 +1,62 @@ +[ + { + "major_versions": [ + 4 + ], + "req": { + "cmd": "invite_new_shamir_recovery", + "fields": [ + { + "name": "claimer_user_id", + "type": "UserID" + }, + { + "name": "send_email", + "type": "Boolean" + } + ] + }, + "reps": [ + { + "status": "ok", + "fields": [ + { + "name": "token", + "type": "InvitationToken" + }, + // Field used when the invitation is correctly created but the invitation email cannot be sent + { + "name": "email_sent", + "type": "InvitationEmailSentStatus" + } + ] + }, + { + "status": "author_not_allowed" + }, + { + "status": "user_not_found" + } + ], + "nested_types": [ + { + "name": "InvitationEmailSentStatus", + "variants": [ + { + // Also returned when `send_email=false` + "name": "Success", + "discriminant_value": "SUCCESS" + }, + { + "name": "ServerUnavailable", + "discriminant_value": "SERVER_UNAVAILABLE" + }, + { + "name": "RecipientRefused", + "discriminant_value": "RECIPIENT_REFUSED" + } + ] + } + ] + } +] diff --git a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 index 48e532179bd..06a245e2584 100644 --- a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 +++ b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 @@ -26,13 +26,25 @@ "type": "String" }, { + // TODO: merge into `created_by` "name": "greeter_user_id", "type": "UserID" }, { + // TODO: merge into `created_by` "name": "greeter_human_handle", "type": "HumanHandle" } + // TODO: Add a `created_by` field and make it an `InviteInfoCreatedBy` type + // which is a nested variant of either: + // - OrganizationAdministrator + // - ExternalService + // + // TODO: Add an `administrators` field and make it a `List` type + // which is a nested type with the fields: + // - user_id: UserID + // - human_handle: HumanHandle + // - status: ONLINE | OFFLINE | UNKNOWN ] }, { @@ -40,14 +52,55 @@ "discriminant_value": "DEVICE", "fields": [ { + // TODO: Rename to `claimer_user_id` "name": "greeter_user_id", "type": "UserID" }, { + // TODO: Rename to `claimer_human_handle` "name": "greeter_human_handle", "type": "HumanHandle" } ] + }, + { + "name": "ShamirRecovery", + "discriminant_value": "SHAMIR_RECOVERY", + "fields": [ + { + "name": "claimer_user_id", + "type": "UserID" + }, + { + "name": "claimer_human_handle", + "type": "HumanHandle" + }, + { + "name": "threshold", + "type": "NonZeroInteger" + }, + { + "name": "recipients", + "type": "List" + } + ] + } + ] + }, + { + "name": "ShamirRecoveryRecipient", + "fields": [ + { + "name": "user_id", + "type": "UserID" + }, + { + "name": "human_handle", + "type": "HumanHandle" + }, + { + "name": "shares", + "type": "NonZeroInteger" } ] } diff --git a/libparsec/crates/protocol/src/version.rs b/libparsec/crates/protocol/src/version.rs index 6130694e42a..b79a121ff58 100644 --- a/libparsec/crates/protocol/src/version.rs +++ b/libparsec/crates/protocol/src/version.rs @@ -11,6 +11,9 @@ use libparsec_types::prelude::*; // - v3.1 (Parsec 2.10+): Add `user_revoked` return status to `realm_update_role` command // - v3.2 (Parsec 2.11+): Sequester API // v4 (Parsec 3.0+): `certificate_get` command & `certificate_updated` event +// - v4.1 (Parsec 3.2+): +// * Add `ShamirRecovery` variants to `invite_list` and `invite_info` +// * Add `invite_new_shamir_recovery` command pub const API_V1_VERSION: &ApiVersion = &ApiVersion { version: 1, revision: 3, @@ -25,7 +28,7 @@ pub const API_V3_VERSION: &ApiVersion = &ApiVersion { }; pub const API_V4_VERSION: &ApiVersion = &ApiVersion { version: 4, - revision: 0, + revision: 1, }; pub const API_LATEST_VERSION: &ApiVersion = API_V4_VERSION; diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_list.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_list.rs index 3170ae86d17..a400cbcf8b4 100644 --- a/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_list.rs +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_list.rs @@ -92,4 +92,75 @@ pub fn rep_ok() { let data2 = authenticated_cmds::invite_list::Rep::load(&raw2).unwrap(); p_assert_eq!(data2, expected); + + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // status: 'ok' + // invitations: [ + // { + // type: 'USER', + // claimer_email: 'alice@example.com', + // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // status: 'IDLE', + // token: 0xd864b93ded264aae9ae583fd3d40c45a, + // }, + // { + // type: 'DEVICE', + // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // status: 'IDLE', + // token: 0xd864b93ded264aae9ae583fd3d40c45a, + // }, + // { + // type: 'SHAMIR_RECOVERY', + // claimer_user_id: ext(2, 0xa11cec00100000000000000000000000), + // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // status: 'IDLE', + // token: 0xd864b93ded264aae9ae583fd3d40c45a, + // }, + // ] + let raw: &[u8] = hex!( + "82a6737461747573a26f6bab696e7669746174696f6e739385a474797065a455534552" + "ad636c61696d65725f656d61696cb1616c696365406578616d706c652e636f6daa6372" + "65617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b65" + "6ec410d864b93ded264aae9ae583fd3d40c45a84a474797065a6444556494345aa6372" + "65617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b65" + "6ec410d864b93ded264aae9ae583fd3d40c45a85a474797065af5348414d49525f5245" + "434f56455259af636c61696d65725f757365725f6964d802a11cec0010000000000000" + "0000000000aa637265617465645f6f6ed70100035d162fa2e400a6737461747573a449" + "444c45a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a" + ) + .as_ref(); + + let expected = authenticated_cmds::invite_list::Rep::Ok { + invitations: vec![ + authenticated_cmds::invite_list::InviteListItem::User { + token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), + created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + claimer_email: "alice@example.com".to_owned(), + status: InvitationStatus::Idle, + }, + authenticated_cmds::invite_list::InviteListItem::Device { + token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), + created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + status: InvitationStatus::Idle, + }, + authenticated_cmds::invite_list::InviteListItem::ShamirRecovery { + token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), + created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + claimer_user_id: "alice".parse().unwrap(), + status: InvitationStatus::Idle, + }, + ], + }; + + let data = authenticated_cmds::invite_list::Rep::load(raw).unwrap(); + println!("***expected: {:?}", expected.dump().unwrap()); + p_assert_eq!(data, expected); + + // Also test serialization round trip + let raw2 = data.dump().unwrap(); + + let data2 = authenticated_cmds::invite_list::Rep::load(&raw2).unwrap(); + + p_assert_eq!(data2, expected); } diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs new file mode 100644 index 00000000000..fb8c326935b --- /dev/null +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs @@ -0,0 +1,120 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +// `allow-unwrap-in-test` don't behave as expected, see: +// https://github.com/rust-lang/rust-clippy/issues/11119 +#![allow(clippy::unwrap_used)] + +use super::authenticated_cmds; + +use libparsec_tests_lite::{hex, p_assert_eq}; +use libparsec_types::InvitationToken; +use libparsec_types::UserID; + +// Request + +pub fn req() { + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // cmd: 'invite_new_shamir_recovery' + // claimer_user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a) + // send_email: True + let raw: &[u8] = hex!( + "83a3636d64ba696e766974655f6e65775f7368616d69725f7265636f76657279af636c" + "61696d65725f757365725f6964d802109b68ba5cdf428ea0017fc6bcc04d4aaa73656e" + "645f656d61696cc3" + ) + .as_ref(); + + let req = authenticated_cmds::invite_new_shamir_recovery::Req { + send_email: true, + claimer_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }; + println!("***expected: {:?}", req.dump().unwrap()); + + let expected = authenticated_cmds::AnyCmdReq::InviteNewShamirRecovery(req); + let data = authenticated_cmds::AnyCmdReq::load(raw).unwrap(); + + p_assert_eq!(data, expected); + + // Also test serialization round trip + let authenticated_cmds::AnyCmdReq::InviteNewShamirRecovery(data2) = data else { + unreachable!() + }; + let raw2 = data2.dump().unwrap(); + + let data2 = authenticated_cmds::AnyCmdReq::load(&raw2).unwrap(); + + p_assert_eq!(data2, expected); +} + +// Responses + +pub fn rep_ok() { + // Generated from Rust implementation (Parsec v3.0.0-b.6+dev 2024-03-29) + // Content: + // status: "ok" + // token: ext(2, hex!("d864b93ded264aae9ae583fd3d40c45a")) + // + // Note that raw data does not contain "email_sent". + // This was valid behavior in api v2 but is no longer valid from v3 onwards. + // The corresponding expected values used here are therefore not important + // since loading raw data should fail. + // + let raw = hex!("82a6737461747573a26f6ba5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a"); + let err = authenticated_cmds::invite_new_shamir_recovery::Rep::load(&raw).unwrap_err(); + p_assert_eq!(err.to_string(), "missing field `email_sent`"); + + // Generated from Python implementation (Parsec v3.0.0-b.6+dev 2024-03-29) + // Content: + // email_sent: "SUCCESS" + // status: "ok" + // token: ext(2, hex!("d864b93ded264aae9ae583fd3d40c45a")) + let raw = hex!( + "83aa656d61696c5f73656e74a753554343455353a6737461747573a26f6ba5746f6b656ec4" + "10d864b93ded264aae9ae583fd3d40c45a" + ); + let expected = authenticated_cmds::invite_new_shamir_recovery::Rep::Ok { + token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), + email_sent: + authenticated_cmds::invite_new_shamir_recovery::InvitationEmailSentStatus::Success, + }; + + rep_helper(&raw, expected); +} + +pub fn rep_author_not_allowed() { + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // status: 'author_not_allowed' + let raw: &[u8] = hex!("81a6737461747573b2617574686f725f6e6f745f616c6c6f776564").as_ref(); + + let expected = authenticated_cmds::invite_new_shamir_recovery::Rep::AuthorNotAllowed; + println!("***expected: {:?}", expected.dump().unwrap()); + + rep_helper(raw, expected); +} + +pub fn rep_user_not_found() { + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // status: 'user_not_found' + let raw: &[u8] = hex!("81a6737461747573ae757365725f6e6f745f666f756e64").as_ref(); + + let expected = authenticated_cmds::invite_new_shamir_recovery::Rep::UserNotFound; + println!("***expected: {:?}", expected.dump().unwrap()); + + rep_helper(raw, expected); +} + +fn rep_helper(raw: &[u8], expected: authenticated_cmds::invite_new_shamir_recovery::Rep) { + let data = authenticated_cmds::invite_new_shamir_recovery::Rep::load(raw).unwrap(); + + p_assert_eq!(data, expected); + + // Also test serialization round trip + let raw2 = data.dump().unwrap(); + + let data2 = authenticated_cmds::invite_new_shamir_recovery::Rep::load(&raw2).unwrap(); + + p_assert_eq!(data2, expected); +} diff --git a/libparsec/crates/protocol/tests/invited_cmds/v4/invite_info.rs b/libparsec/crates/protocol/tests/invited_cmds/v4/invite_info.rs index d055caaac4e..6124977bfa2 100644 --- a/libparsec/crates/protocol/tests/invited_cmds/v4/invite_info.rs +++ b/libparsec/crates/protocol/tests/invited_cmds/v4/invite_info.rs @@ -81,11 +81,50 @@ pub fn rep_ok() { greeter_human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), }), ), + ( + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // status: 'ok' + // type: 'SHAMIR_RECOVERY' + // claimer_human_handle: [ 'carl@example.com', 'carl', ] + // claimer_user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4c) + // recipients: [ { human_handle: [ 'alice@example.com', 'alice', ], shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'bob@example.com', 'bob', ], shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // threshold: 2 + &hex!( + "86a6737461747573a26f6ba474797065af5348414d49525f5245434f56455259b4636c" + "61696d65725f68756d616e5f68616e646c6592b06361726c406578616d706c652e636f" + "6da46361726caf636c61696d65725f757365725f6964d802109b68ba5cdf428ea0017f" + "c6bcc04d4caa726563697069656e74739283ac68756d616e5f68616e646c6592b1616c" + "696365406578616d706c652e636f6da5616c696365a673686172657301a7757365725f" + "6964d802109b68ba5cdf428ea0017fc6bcc04d4a83ac68756d616e5f68616e646c6592" + "af626f62406578616d706c652e636f6da3626f62a673686172657301a7757365725f69" + "64d802109b68ba5cdf428ea0017fc6bcc04d4ba97468726573686f6c6402" + )[..], + invited_cmds::invite_info::Rep::Ok( + invited_cmds::invite_info::UserOrDevice::ShamirRecovery { + claimer_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4c").unwrap(), + claimer_human_handle: HumanHandle::new("carl@example.com", "carl").unwrap(), + recipients: vec![ + invited_cmds::invite_info::ShamirRecoveryRecipient { + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + human_handle: HumanHandle::new("alice@example.com", "alice").unwrap(), + shares: 1.try_into().unwrap(), + }, + invited_cmds::invite_info::ShamirRecoveryRecipient { + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), + human_handle: HumanHandle::new("bob@example.com", "bob").unwrap(), + shares: 1.try_into().unwrap(), + }, + ], + threshold: 2.try_into().unwrap(), + }, + ), + ), ]; for (raw, expected) in raw_expected { + println!("***expected: {:?}", expected.dump().unwrap()); let data = invited_cmds::invite_info::Rep::load(raw).unwrap(); - assert_eq!(data, expected); // Also test serialization round trip diff --git a/libparsec/crates/testbed/src/template/build.rs b/libparsec/crates/testbed/src/template/build.rs index ee37b06d191..ce68820b88b 100644 --- a/libparsec/crates/testbed/src/template/build.rs +++ b/libparsec/crates/testbed/src/template/build.rs @@ -590,6 +590,19 @@ impl<'a> TestbedEventNewUserInvitationBuilder<'a> { impl_customize_field_meth!(token, InvitationToken); } +/* + * TestbedEventNewShamirRecoveryInvitation + */ + +impl_event_builder!(NewShamirRecoveryInvitation, [claimer: UserID]); + +impl<'a> TestbedEventNewShamirRecoveryInvitationBuilder<'a> { + impl_customize_field_meth!(claimer, UserID); + impl_customize_field_meth!(created_by, DeviceID); + impl_customize_field_meth!(created_on, DateTime); + impl_customize_field_meth!(token, InvitationToken); +} + /* * TestbedEventNewRealmBuilder */ diff --git a/libparsec/crates/testbed/src/template/events.rs b/libparsec/crates/testbed/src/template/events.rs index 3291e59f111..6877beb79b9 100644 --- a/libparsec/crates/testbed/src/template/events.rs +++ b/libparsec/crates/testbed/src/template/events.rs @@ -161,6 +161,7 @@ pub enum TestbedEvent { // 2) Client/server interaction events not producing certificates NewDeviceInvitation(TestbedEventNewDeviceInvitation), NewUserInvitation(TestbedEventNewUserInvitation), + NewShamirRecoveryInvitation(TestbedEventNewShamirRecoveryInvitation), CreateOrUpdateUserManifestVlob(TestbedEventCreateOrUpdateUserManifestVlob), CreateOrUpdateFileManifestVlob(TestbedEventCreateOrUpdateFileManifestVlob), CreateOrUpdateFolderManifestVlob(TestbedEventCreateOrUpdateFolderManifestVlob), @@ -200,6 +201,7 @@ impl CrcHash for TestbedEvent { TestbedEvent::RevokeUser(x) => x.crc_hash(hasher), TestbedEvent::NewDeviceInvitation(x) => x.crc_hash(hasher), TestbedEvent::NewUserInvitation(x) => x.crc_hash(hasher), + TestbedEvent::NewShamirRecoveryInvitation(x) => x.crc_hash(hasher), TestbedEvent::NewRealm(x) => x.crc_hash(hasher), TestbedEvent::ShareRealm(x) => x.crc_hash(hasher), TestbedEvent::RenameRealm(x) => x.crc_hash(hasher), @@ -374,6 +376,7 @@ impl TestbedEvent { TestbedEvent::NewDeviceInvitation(_) | TestbedEvent::NewUserInvitation(_) + | TestbedEvent::NewShamirRecoveryInvitation(_) | TestbedEvent::CreateOrUpdateUserManifestVlob(_) | TestbedEvent::CreateOrUpdateFileManifestVlob(_) | TestbedEvent::CreateOrUpdateFolderManifestVlob(_) @@ -409,6 +412,7 @@ impl TestbedEvent { | TestbedEvent::RevokeUser(_) | TestbedEvent::NewDeviceInvitation(_) | TestbedEvent::NewUserInvitation(_) + | TestbedEvent::NewShamirRecoveryInvitation(_) | TestbedEvent::NewRealm(_) | TestbedEvent::ShareRealm(_) | TestbedEvent::RenameRealm(_) @@ -2372,6 +2376,72 @@ impl TestbedEventNewUserInvitation { } } +/* + * TestbedEventNewShamirRecoveryInvitation + */ + +no_certificate_event!( + TestbedEventNewShamirRecoveryInvitation, + [ + claimer: UserID, + created_by: DeviceID, + created_on: DateTime, + token: InvitationToken, + ] +); + +impl TestbedEventNewShamirRecoveryInvitation { + pub(super) fn from_builder(builder: &mut TestbedTemplateBuilder, claimer: UserID) -> Self { + // 1) Consistency checks + + if builder.check_consistency { + utils::assert_organization_bootstrapped(&builder.events); + utils::assert_user_exists_and_not_revoked(&builder.events, claimer); + } + + let recipients = + match utils::assert_user_has_non_deleted_shamir_recovery(&builder.events, claimer) { + TestbedEvent::NewShamirRecovery(x) => { + &x.per_recipient_shares //.iter().map(|(user_id, _)| *user_id).collect::>() + } + _ => unreachable!(), + }; + // It's important to iterate over the recipients (instead of filtering with + // the recipients the list of non revoked users). + // This is to ensure the chosen creator is selected according to the order + // of declaration of the recipients. + let created_by = recipients + .iter() + .find_map(|(recipient, _)| { + utils::non_revoked_users_each_devices(&builder.events).find_map( + |(candidate_user_id, candidate_device_id)| { + if candidate_user_id == *recipient { + Some(candidate_device_id) + } else { + None + } + }, + ) + }) + .unwrap_or_else(|| { + panic!( + "All recipients ({:?}) appear to be revoked or missing", + recipients + ) + }); + + // 2) Actual creation + + let token = builder.counters.next_invitation_token(); + Self { + claimer, + created_on: builder.counters.next_timestamp(), + created_by, + token, + } + } +} + /* * TestbedEventCreateOrUpdateUserManifestVlob */ diff --git a/libparsec/crates/testbed/src/template/utils.rs b/libparsec/crates/testbed/src/template/utils.rs index 97cc0d3b622..a18a1c0ee3c 100644 --- a/libparsec/crates/testbed/src/template/utils.rs +++ b/libparsec/crates/testbed/src/template/utils.rs @@ -346,3 +346,20 @@ pub(super) fn assert_realm_exists(events: &[TestbedEvent], realm: VlobID) { .find(|e| matches!(e, TestbedEvent::NewRealm(x) if x.realm_id == realm)) .unwrap_or_else(|| panic!("Realm {} doesn't exist", realm)); } + +pub(super) fn assert_user_has_non_deleted_shamir_recovery( + events: &'_ [TestbedEvent], + user: UserID, +) -> &'_ TestbedEvent { + events + .iter() + .rev() + .find(|e| match e { + TestbedEvent::NewShamirRecovery(x) if x.user_id == user => true, + TestbedEvent::DeleteShamirRecovery(x) if x.setup_to_delete_user_id == user => { + panic!("User {}'s Shamir recovery is deleted !", user) + } + _ => false, + }) + .unwrap_or_else(|| panic!("User {} doesn't have any Shamir recovery", user)) +} diff --git a/libparsec/crates/testbed/src/templates/shamir.rs b/libparsec/crates/testbed/src/templates/shamir.rs index da6dab925fe..3f136577d6c 100644 --- a/libparsec/crates/testbed/src/templates/shamir.rs +++ b/libparsec/crates/testbed/src/templates/shamir.rs @@ -16,6 +16,7 @@ use crate::TestbedTemplate; /// Mallory & mike with 1 share each) /// - Bob has a deleted shamir recovery (used to be threshold: 1, recipients: Alice & Mallory) /// - Mallory has a shamir recovery setup (threshold: 1, recipients: only Mike) +/// - Bob has invited Alice to do a Shamir recovery /// - devices `alice@dev1`/`bob@dev1`/`mallory@dev1`/`mike@dev1` starts with up-to-date storages /// - devices `alice@dev2` and `bob@dev2` whose storages are empty pub(crate) fn generate() -> Arc { @@ -72,6 +73,8 @@ pub(crate) fn generate() -> Arc { "mallory@dev2", ); + builder.new_shamir_recovery_invitation("alice"); + // 3) Initialize client storages for alice@dev1/bob@dev1/mallory@dev1/mike@dev1 builder.certificates_storage_fetch_certificates("alice@dev1"); diff --git a/libparsec/crates/types/src/addr.rs b/libparsec/crates/types/src/addr.rs index 9badc4d4555..4b593f8583b 100644 --- a/libparsec/crates/types/src/addr.rs +++ b/libparsec/crates/types/src/addr.rs @@ -35,6 +35,7 @@ const PARSEC_ACTION_BOOTSTRAP_ORGANIZATION: &str = "bootstrap_organization"; const PARSEC_ACTION_WORKSPACE_PATH: &str = "path"; const PARSEC_ACTION_CLAIM_USER: &str = "claim_user"; const PARSEC_ACTION_CLAIM_DEVICE: &str = "claim_device"; +const PARSEC_ACTION_CLAIM_SHAMIR_RECOVERY: &str = "claim_shamir_recovery"; const PARSEC_ACTION_PKI_ENROLLMENT: &str = "pki_enrollment"; /// Url has a special way to parse http/https schemes. This is because those kind @@ -824,15 +825,18 @@ impl ParsecInvitationAddr { let invitation_type = match extract_param(&pairs, PARSEC_PARAM_ACTION)? { x if x == PARSEC_ACTION_CLAIM_USER => InvitationType::User, x if x == PARSEC_ACTION_CLAIM_DEVICE => InvitationType::Device, + x if x == PARSEC_ACTION_CLAIM_SHAMIR_RECOVERY => InvitationType::ShamirRecovery, _ => { return Err(AddrError::InvalidParamValue { param: PARSEC_PARAM_ACTION, help: format!( - "Expected `{}={}` or `{}={}`", + "Expected `{}={}`, `{}={}` or `{}={}`", PARSEC_PARAM_ACTION, PARSEC_ACTION_CLAIM_USER, PARSEC_PARAM_ACTION, - PARSEC_ACTION_CLAIM_DEVICE + PARSEC_ACTION_CLAIM_DEVICE, + PARSEC_PARAM_ACTION, + PARSEC_ACTION_CLAIM_SHAMIR_RECOVERY ), }) } @@ -866,6 +870,7 @@ impl ParsecInvitationAddr { match self.invitation_type() { InvitationType::User => PARSEC_ACTION_CLAIM_USER, InvitationType::Device => PARSEC_ACTION_CLAIM_DEVICE, + InvitationType::ShamirRecovery => PARSEC_ACTION_CLAIM_SHAMIR_RECOVERY, }, ) .append_pair(PARSEC_PARAM_PAYLOAD, &payload); diff --git a/libparsec/crates/types/src/invite.rs b/libparsec/crates/types/src/invite.rs index 1c38b2b593f..0fba07b69cf 100644 --- a/libparsec/crates/types/src/invite.rs +++ b/libparsec/crates/types/src/invite.rs @@ -20,10 +20,11 @@ use crate::{ */ #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum InvitationType { User, Device, + ShamirRecovery, } #[derive(Debug, Clone)] @@ -54,6 +55,7 @@ impl Display for InvitationType { match self { Self::User => write!(f, "USER"), Self::Device => write!(f, "DEVICE"), + Self::ShamirRecovery => write!(f, "SHAMIR_RECOVERY"), } } } diff --git a/libparsec/crates/types/tests/unit/addr.rs b/libparsec/crates/types/tests/unit/addr.rs index c0d66530935..fc4fb127050 100644 --- a/libparsec/crates/types/tests/unit/addr.rs +++ b/libparsec/crates/types/tests/unit/addr.rs @@ -489,7 +489,8 @@ fn invitation_addr_bad_type( &url, AddrError::InvalidParamValue { param: "a", - help: "Expected `a=claim_user` or `a=claim_device`".to_string(), + help: "Expected `a=claim_user`, `a=claim_device` or `a=claim_shamir_recovery`" + .to_string(), }, ); } diff --git a/libparsec/src/addr.rs b/libparsec/src/addr.rs index a652f2427dd..b6a67d2fb4d 100644 --- a/libparsec/src/addr.rs +++ b/libparsec/src/addr.rs @@ -50,6 +50,13 @@ pub enum ParsedParsecAddr { organization_id: OrganizationID, token: InvitationToken, }, + InvitationShamirRecovery { + hostname: String, + port: u32, + use_ssl: bool, + organization_id: OrganizationID, + token: InvitationToken, + }, PkiEnrollment { hostname: String, port: u32, @@ -94,6 +101,13 @@ pub fn parse_parsec_addr(url: &str) -> Result ParsedParsecAddr::InvitationShamirRecovery { + hostname: addr.hostname().into(), + port: addr.port() as u32, + use_ssl: addr.use_ssl(), + organization_id: addr.organization_id().clone(), + token: addr.token(), + }, }, ParsecActionAddr::PkiEnrollment(addr) => ParsedParsecAddr::PkiEnrollment { hostname: addr.hostname().into(), diff --git a/libparsec/src/invite.rs b/libparsec/src/invite.rs index f8a5570f63c..e1db29bbdc0 100644 --- a/libparsec/src/invite.rs +++ b/libparsec/src/invite.rs @@ -3,7 +3,8 @@ use std::sync::Arc; pub use libparsec_client::{ - ClientCancelInvitationError, ClientNewDeviceInvitationError, ClientNewUserInvitationError, + ClientCancelInvitationError, ClientNewDeviceInvitationError, + ClientNewShamirRecoveryInvitationError, ClientNewUserInvitationError, InvitationEmailSentStatus, ListInvitationsError, }; pub use libparsec_types::prelude::*; @@ -755,6 +756,32 @@ pub async fn client_new_device_invitation( }) } +pub async fn client_new_shamir_recovery_invitation( + client: Handle, + claimer_user_id: UserID, + send_email: bool, +) -> Result { + let client = borrow_from_handle(client, |x| match x { + HandleItem::Client { client, .. } => Some(client.clone()), + _ => None, + })?; + + let (token, email_sent_status) = client + .new_shamir_recovery_invitation(claimer_user_id, send_email) + .await?; + + Ok(NewInvitationInfo { + addr: ParsecInvitationAddr::new( + client.organization_addr(), + client.organization_id().to_owned(), + InvitationType::ShamirRecovery, + token, + ), + token, + email_sent_status, + }) +} + pub async fn client_cancel_invitation( client: Handle, token: InvitationToken, @@ -783,6 +810,13 @@ pub enum InviteListItem { created_on: DateTime, status: InvitationStatus, }, + ShamirRecovery { + addr: ParsecInvitationAddr, + token: InvitationToken, + created_on: DateTime, + claimer_user_id: UserID, + status: InvitationStatus, + }, } pub async fn client_list_invitations( @@ -836,6 +870,26 @@ pub async fn client_list_invitations( token, } } + libparsec_client::InviteListItem::ShamirRecovery { + created_on, + status, + token, + claimer_user_id, + } => { + let addr = ParsecInvitationAddr::new( + client.organization_addr(), + client.organization_id().to_owned(), + InvitationType::ShamirRecovery, + token, + ); + InviteListItem::ShamirRecovery { + addr, + created_on, + status, + token, + claimer_user_id, + } + } }) .collect(); diff --git a/misc/versions.toml b/misc/versions.toml index 5009b6b1284..0c9c109328b 100644 --- a/misc/versions.toml +++ b/misc/versions.toml @@ -8,6 +8,6 @@ nextest = "0.9.54" license = "BUSL-1.1" postgres = "14.10" winfsp = "2.0.23075" -testbed = "3.1.1-a.0.dev.20041.677dcc5" +testbed = "3.1.1-a.0.dev.20049.b16ce69" pre-commit = "3.7.1" cross = "v0.2.5" diff --git a/server/packaging/testbed-server/README.md b/server/packaging/testbed-server/README.md index 04e0b7924d5..996d1863f3d 100644 --- a/server/packaging/testbed-server/README.md +++ b/server/packaging/testbed-server/README.md @@ -36,7 +36,7 @@ For example, `https://github.com/Scille/parsec-cloud/blob/master/.github/workflo ```yaml services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20041.677dcc5 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.1.1-a.0.dev.20049.b16ce69 ``` ## Build and Publish a new testbed server Docker image diff --git a/server/parsec/_parsec.pyi b/server/parsec/_parsec.pyi index f56a490e3f4..fb3356e924b 100644 --- a/server/parsec/_parsec.pyi +++ b/server/parsec/_parsec.pyi @@ -70,7 +70,6 @@ from parsec._parsec_pyi.ids import ( InvitationToken, OrganizationID, SequesterServiceID, - ShamirRevealToken, UserID, VlobID, ) @@ -160,7 +159,6 @@ __all__ = [ "EnrollmentID", "BootstrapToken", "InvitationToken", - "ShamirRevealToken", "GreetingAttemptID", # Addrs "ParsecAddr", diff --git a/server/parsec/_parsec_pyi/enumerate.pyi b/server/parsec/_parsec_pyi/enumerate.pyi index a1bdf287631..ca61d2e1d71 100644 --- a/server/parsec/_parsec_pyi/enumerate.pyi +++ b/server/parsec/_parsec_pyi/enumerate.pyi @@ -17,6 +17,7 @@ class InvitationStatus: class InvitationType: DEVICE: InvitationType USER: InvitationType + SHAMIR_RECOVERY: InvitationType VALUES: tuple[InvitationType, ...] @classmethod diff --git a/server/parsec/_parsec_pyi/ids.pyi b/server/parsec/_parsec_pyi/ids.pyi index f5ee818fb64..a2af257ae68 100644 --- a/server/parsec/_parsec_pyi/ids.pyi +++ b/server/parsec/_parsec_pyi/ids.pyi @@ -252,23 +252,6 @@ class BootstrapToken: @property def hyphenated(self) -> str: ... -class ShamirRevealToken: - def __hash__(self) -> int: ... - @classmethod - def from_bytes(cls, bytes: bytes) -> ShamirRevealToken: ... - @classmethod - def from_hex(cls, hex: str) -> ShamirRevealToken: ... - @classmethod - def new(cls) -> ShamirRevealToken: ... - @property - def bytes(self) -> bytes: ... - @property - def hex(self) -> str: ... - @property - def int(self) -> int: ... - @property - def hyphenated(self) -> str: ... - class GreetingAttemptID: def __lt__(self, other: GreetingAttemptID) -> bool: ... def __gt__(self, other: GreetingAttemptID) -> bool: ... diff --git a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/__init__.pyi b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/__init__.pyi index 354f3a3ae50..0eca66fe766 100644 --- a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/__init__.pyi +++ b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/__init__.pyi @@ -17,6 +17,7 @@ from . import ( invite_greeter_step, invite_list, invite_new_device, + invite_new_shamir_recovery, invite_new_user, list_frozen_users, ping, @@ -57,6 +58,7 @@ class AnyCmdReq: | invite_greeter_step.Req | invite_list.Req | invite_new_device.Req + | invite_new_shamir_recovery.Req | invite_new_user.Req | list_frozen_users.Req | ping.Req @@ -94,6 +96,7 @@ __all__ = [ "invite_greeter_step", "invite_list", "invite_new_device", + "invite_new_shamir_recovery", "invite_new_user", "list_frozen_users", "ping", diff --git a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_list.pyi b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_list.pyi index 3dc209ee765..7b4eae4d1ae 100644 --- a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_list.pyi +++ b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_list.pyi @@ -4,7 +4,7 @@ from __future__ import annotations -from parsec._parsec import DateTime, InvitationStatus, InvitationToken +from parsec._parsec import DateTime, InvitationStatus, InvitationToken, UserID class InviteListItem: pass @@ -37,6 +37,23 @@ class InviteListItemDevice(InviteListItem): @property def token(self) -> InvitationToken: ... +class InviteListItemShamirRecovery(InviteListItem): + def __init__( + self, + token: InvitationToken, + created_on: DateTime, + claimer_user_id: UserID, + status: InvitationStatus, + ) -> None: ... + @property + def claimer_user_id(self) -> UserID: ... + @property + def created_on(self) -> DateTime: ... + @property + def status(self) -> InvitationStatus: ... + @property + def token(self) -> InvitationToken: ... + class Req: def __init__( self, diff --git a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi new file mode 100644 index 00000000000..78561b2b739 --- /dev/null +++ b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi @@ -0,0 +1,55 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +# /!\ Autogenerated by misc/gen_protocol_typings.py, any modification will be lost ! + +from __future__ import annotations + +from parsec._parsec import InvitationToken, UserID + +class InvitationEmailSentStatus: + VALUES: tuple[InvitationEmailSentStatus] + SUCCESS: InvitationEmailSentStatus + SERVER_UNAVAILABLE: InvitationEmailSentStatus + RECIPIENT_REFUSED: InvitationEmailSentStatus + + @classmethod + def from_str(cls, value: str) -> InvitationEmailSentStatus: ... + @property + def str(self) -> str: ... + +class Req: + def __init__(self, claimer_user_id: UserID, send_email: bool) -> None: ... + def dump(self) -> bytes: ... + @property + def claimer_user_id(self) -> UserID: ... + @property + def send_email(self) -> bool: ... + +class Rep: + @staticmethod + def load(raw: bytes) -> Rep: ... + def dump(self) -> bytes: ... + +class RepUnknownStatus(Rep): + def __init__(self, status: str, reason: str | None) -> None: ... + @property + def status(self) -> str: ... + @property + def reason(self) -> str | None: ... + +class RepOk(Rep): + def __init__(self, token: InvitationToken, email_sent: InvitationEmailSentStatus) -> None: ... + @property + def email_sent(self) -> InvitationEmailSentStatus: ... + @property + def token(self) -> InvitationToken: ... + +class RepAuthorNotAllowed(Rep): + def __init__( + self, + ) -> None: ... + +class RepUserNotFound(Rep): + def __init__( + self, + ) -> None: ... diff --git a/server/parsec/_parsec_pyi/protocol/invited_cmds/v4/invite_info.pyi b/server/parsec/_parsec_pyi/protocol/invited_cmds/v4/invite_info.pyi index 698e9e5d1fe..83b4676b9e5 100644 --- a/server/parsec/_parsec_pyi/protocol/invited_cmds/v4/invite_info.pyi +++ b/server/parsec/_parsec_pyi/protocol/invited_cmds/v4/invite_info.pyi @@ -27,6 +27,32 @@ class UserOrDeviceDevice(UserOrDevice): @property def greeter_user_id(self) -> UserID: ... +class UserOrDeviceShamirRecovery(UserOrDevice): + def __init__( + self, + claimer_user_id: UserID, + claimer_human_handle: HumanHandle, + threshold: int, + recipients: list[ShamirRecoveryRecipient], + ) -> None: ... + @property + def claimer_human_handle(self) -> HumanHandle: ... + @property + def claimer_user_id(self) -> UserID: ... + @property + def recipients(self) -> list[ShamirRecoveryRecipient]: ... + @property + def threshold(self) -> int: ... + +class ShamirRecoveryRecipient: + def __init__(self, user_id: UserID, human_handle: HumanHandle, shares: int) -> None: ... + @property + def human_handle(self) -> HumanHandle: ... + @property + def shares(self) -> int: ... + @property + def user_id(self) -> UserID: ... + class Req: def __init__( self, diff --git a/server/parsec/_parsec_pyi/testbed.pyi b/server/parsec/_parsec_pyi/testbed.pyi index 25db377ea38..9ed8539ea74 100644 --- a/server/parsec/_parsec_pyi/testbed.pyi +++ b/server/parsec/_parsec_pyi/testbed.pyi @@ -162,6 +162,12 @@ class TestbedEventNewDeviceInvitation: created_on: DateTime token: InvitationToken +class TestbedEventNewShamirRecoveryInvitation: + claimer: UserID + created_by: DeviceID + created_on: DateTime + token: InvitationToken + class TestbedEventNewRealm: timestamp: DateTime author: DeviceID diff --git a/server/parsec/backend.py b/server/parsec/backend.py index 9d43f3c296b..08afe62945b 100644 --- a/server/parsec/backend.py +++ b/server/parsec/backend.py @@ -272,6 +272,16 @@ def _get_device_verify_key(device_id: DeviceID) -> VerifyKey: force_token=event.token, ) assert isinstance(outcome, tuple), outcome + elif isinstance(event, testbed.TestbedEventNewShamirRecoveryInvitation): + outcome = await self.invite.new_for_shamir_recovery( + claimer_user_id=event.claimer, + now=event.created_on, + organization_id=org_id, + author=event.created_by, + send_email=False, + force_token=event.token, + ) + assert isinstance(outcome, tuple), outcome elif isinstance(event, testbed.TestbedEventNewRealm): outcome = await self.realm.create( now=event.timestamp, diff --git a/server/parsec/components/invite.py b/server/parsec/components/invite.py index 75e510169e9..c4892c3c19d 100644 --- a/server/parsec/components/invite.py +++ b/server/parsec/components/invite.py @@ -13,7 +13,7 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from enum import auto -from typing import Callable +from typing import Callable, TypeAlias import anyio @@ -47,6 +47,8 @@ from parsec.templates import get_template from parsec.types import BadOutcome, BadOutcomeEnum +ShamirRecoveryRecipient: TypeAlias = invited_cmds.latest.invite_info.ShamirRecoveryRecipient + logger = get_logger() @@ -73,28 +75,57 @@ class DeviceInvitation: status: InvitationStatus -Invitation = UserInvitation | DeviceInvitation +@dataclass(slots=True) +class ShamirRecoveryInvitation: + TYPE = InvitationType.SHAMIR_RECOVERY + created_by_user_id: UserID + created_by_device_id: DeviceID + created_by_human_handle: HumanHandle + token: InvitationToken + created_on: DateTime + status: InvitationStatus + + # Shamir-specific fields + claimer_user_id: UserID + claimer_human_handle: HumanHandle + threshold: int + recipients: list[ShamirRecoveryRecipient] + + +Invitation = UserInvitation | DeviceInvitation | ShamirRecoveryInvitation def generate_invite_email( + invitation_type: InvitationType, from_addr: str, to_addr: str, - reply_to: str | None, - greeter_name: str | None, # None for device invitation organization_id: OrganizationID, invitation_url: str, server_url: str, + reply_to: str | None = None, + greeter_name: str | None = None, ) -> Message: # Quick fix to have a similar behavior between Rust and Python if server_url.endswith("/"): server_url = server_url[:-1] + + is_user_invitation = invitation_type == invitation_type.USER + is_device_invitation = invitation_type == invitation_type.DEVICE + is_shamir_recovery_invitation = invitation_type == invitation_type.SHAMIR_RECOVERY + html = get_template("invitation_mail.html").render( + is_user_invitation=is_user_invitation, + is_device_invitation=is_device_invitation, + is_shamir_recovery_invitation=is_shamir_recovery_invitation, greeter=greeter_name, organization_id=organization_id.str, invitation_url=invitation_url, server_url=server_url, ) text = get_template("invitation_mail.txt").render( + is_user_invitation=is_user_invitation, + is_device_invitation=is_device_invitation, + is_shamir_recovery_invitation=is_shamir_recovery_invitation, greeter=greeter_name, organization_id=organization_id.str, invitation_url=invitation_url, @@ -213,6 +244,15 @@ class InviteNewForDeviceBadOutcome(BadOutcomeEnum): AUTHOR_REVOKED = auto() +class InviteNewForShamirBadOutcome(BadOutcomeEnum): + ORGANIZATION_NOT_FOUND = auto() + ORGANIZATION_EXPIRED = auto() + AUTHOR_NOT_FOUND = auto() + AUTHOR_REVOKED = auto() + AUTHOR_NOT_ALLOWED = auto() + USER_NOT_FOUND = auto() + + class InviteCancelBadOutcome(BadOutcomeEnum): ORGANIZATION_NOT_FOUND = auto() ORGANIZATION_EXPIRED = auto() @@ -543,6 +583,7 @@ async def _send_user_invitation_email( ).to_http_redirection_url() message = generate_invite_email( + invitation_type=InvitationType.USER, from_addr=self._config.email_config.sender, to_addr=claimer_email, greeter_name=greeter_human_handle.label, @@ -576,10 +617,44 @@ async def _send_device_invitation_email( ).to_http_redirection_url() message = generate_invite_email( + invitation_type=InvitationType.DEVICE, + from_addr=self._config.email_config.sender, + to_addr=email, + organization_id=organization_id, + invitation_url=invitation_url, + server_url=self._config.server_addr.to_http_url(), + ) + + return await send_email( + email_config=self._config.email_config, + to_addr=email, + message=message, + ) + + # Used by `new_for_shamir_recovery` implementations + async def _send_shamir_recovery_invitation_email( + self, + organization_id: OrganizationID, + token: InvitationToken, + email: str, + greeter_human_handle: HumanHandle, + ) -> None | SendEmailBadOutcome: + if not self._config.server_addr: + return SendEmailBadOutcome.BAD_SMTP_CONFIG + + invitation_url = ParsecInvitationAddr.build( + server_addr=self._config.server_addr, + organization_id=organization_id, + invitation_type=InvitationType.SHAMIR_RECOVERY, + token=token, + ).to_http_redirection_url() + + message = generate_invite_email( + invitation_type=InvitationType.SHAMIR_RECOVERY, from_addr=self._config.email_config.sender, to_addr=email, - greeter_name=None, - reply_to=None, + greeter_name=greeter_human_handle.label, + reply_to=greeter_human_handle.email, organization_id=organization_id, invitation_url=invitation_url, server_url=self._config.server_addr.to_http_url(), @@ -618,6 +693,18 @@ async def new_for_device( ) -> tuple[InvitationToken, None | SendEmailBadOutcome] | InviteNewForDeviceBadOutcome: raise NotImplementedError + async def new_for_shamir_recovery( + self, + now: DateTime, + organization_id: OrganizationID, + author: DeviceID, + send_email: bool, + claimer_user_id: UserID, + # Only needed for testbed template + force_token: InvitationToken | None = None, + ) -> tuple[InvitationToken, None | SendEmailBadOutcome] | InviteNewForShamirBadOutcome: + raise NotImplementedError + async def cancel( self, now: DateTime, @@ -729,6 +816,48 @@ async def api_invite_new_device( email_sent=email_sent, ) + @api + async def api_invite_new_shamir_recovery( + self, + client_ctx: AuthenticatedClientContext, + req: authenticated_cmds.latest.invite_new_shamir_recovery.Req, + ) -> authenticated_cmds.latest.invite_new_shamir_recovery.Rep: + outcome = await self.new_for_shamir_recovery( + now=DateTime.now(), + organization_id=client_ctx.organization_id, + author=client_ctx.device_id, + send_email=req.send_email, + claimer_user_id=req.claimer_user_id, + ) + match outcome: + case (InvitationToken() as token, None): + email_sent = authenticated_cmds.latest.invite_new_shamir_recovery.InvitationEmailSentStatus.SUCCESS + case ( + InvitationToken() as token, + SendEmailBadOutcome.BAD_SMTP_CONFIG + | SendEmailBadOutcome.SERVER_UNAVAILABLE, + ): + email_sent = authenticated_cmds.latest.invite_new_shamir_recovery.InvitationEmailSentStatus.SERVER_UNAVAILABLE + case (InvitationToken() as token, SendEmailBadOutcome.RECIPIENT_REFUSED): + email_sent = authenticated_cmds.latest.invite_new_shamir_recovery.InvitationEmailSentStatus.RECIPIENT_REFUSED + case InviteNewForShamirBadOutcome.AUTHOR_NOT_ALLOWED: + return authenticated_cmds.latest.invite_new_shamir_recovery.RepAuthorNotAllowed() + case InviteNewForShamirBadOutcome.USER_NOT_FOUND: + return authenticated_cmds.latest.invite_new_shamir_recovery.RepUserNotFound() + case InviteNewForShamirBadOutcome.ORGANIZATION_NOT_FOUND: + client_ctx.organization_not_found_abort() + case InviteNewForShamirBadOutcome.ORGANIZATION_EXPIRED: + client_ctx.organization_expired_abort() + case InviteNewForShamirBadOutcome.AUTHOR_NOT_FOUND: + client_ctx.author_not_found_abort() + case InviteNewForShamirBadOutcome.AUTHOR_REVOKED: + client_ctx.author_revoked_abort() + + return authenticated_cmds.latest.invite_new_shamir_recovery.RepOk( + token=token, + email_sent=email_sent, + ) + @api async def api_invite_cancel( self, @@ -795,6 +924,13 @@ async def api_invite_list( created_on=invitation.created_on, status=invitation.status, ) + case ShamirRecoveryInvitation(): + cooked = authenticated_cmds.latest.invite_list.InviteListItemShamirRecovery( + token=invitation.token, + created_on=invitation.created_on, + status=invitation.status, + claimer_user_id=invitation.claimer_user_id, + ) cooked_invitations.append(cooked) return authenticated_cmds.latest.invite_list.RepOk(invitations=cooked_invitations) @@ -822,6 +958,15 @@ async def api_invite_info( greeter_human_handle=invitation.created_by_human_handle, ) ) + case ShamirRecoveryInvitation() as invitation: + return invited_cmds.latest.invite_info.RepOk( + invited_cmds.latest.invite_info.UserOrDeviceShamirRecovery( + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=invitation.claimer_human_handle, + threshold=invitation.threshold, + recipients=invitation.recipients, + ) + ) case InviteAsInvitedInfoBadOutcome.ORGANIZATION_NOT_FOUND: client_ctx.organization_not_found_abort() case InviteAsInvitedInfoBadOutcome.ORGANIZATION_EXPIRED: diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index f92d2e839c8..1337ecc4f71 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -289,8 +289,12 @@ class MemoryInvitation: type: InvitationType created_by_user_id: UserID created_by_device_id: DeviceID + # Required for when type=USER claimer_email: str | None + # Required for when type=SHAMIR_RECOVERY + claimer_user_id: UserID | None + created_on: DateTime deleted_on: DateTime | None = None deleted_reason: MemoryInvitationDeletedReason | None = None diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index 9977e3dab98..2a5fab014e2 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -32,9 +32,12 @@ InviteGreeterStepBadOutcome, InviteListBadOutcome, InviteNewForDeviceBadOutcome, + InviteNewForShamirBadOutcome, InviteNewForUserBadOutcome, NotReady, SendEmailBadOutcome, + ShamirRecoveryInvitation, + ShamirRecoveryRecipient, UserInvitation, ) from parsec.components.memory.datamodel import ( @@ -42,6 +45,7 @@ MemoryDatamodel, MemoryInvitation, MemoryInvitationDeletedReason, + MemoryOrganization, MemoryUser, ) from parsec.events import EventInvitation @@ -72,6 +76,40 @@ def _get_invitation_status( else: return InvitationStatus.IDLE + def _get_shamir_recovery_invitation( + self, org: MemoryOrganization, invitation: MemoryInvitation + ) -> ShamirRecoveryInvitation | None: + assert invitation.claimer_user_id is not None + shamir_setup = org.shamir_setup.get(invitation.claimer_user_id) + if shamir_setup is None: + return None + threshold = shamir_setup.brief.threshold + par_recipient_shares = shamir_setup.brief.per_recipient_shares + recipients = [ + ShamirRecoveryRecipient( + user_id=user_id, + human_handle=org.users[user_id].cooked.human_handle, + shares=shares, + ) + for user_id, shares in par_recipient_shares.items() + ] + recipients.sort(key=lambda x: x.human_handle.label) + status = self._get_invitation_status(org.organization_id, invitation) + created_by_human_handle = org.users[invitation.created_by_user_id].cooked.human_handle + claimer_human_handle = org.users[invitation.claimer_user_id].cooked.human_handle + return ShamirRecoveryInvitation( + token=invitation.token, + created_on=invitation.created_on, + created_by_device_id=invitation.created_by_device_id, + created_by_user_id=invitation.created_by_user_id, + created_by_human_handle=created_by_human_handle, + status=status, + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=claimer_human_handle, + threshold=threshold, + recipients=recipients, + ) + @override async def new_for_user( self, @@ -136,6 +174,7 @@ async def new_for_user( created_by_user_id=author_user_id, created_by_device_id=author, claimer_email=claimer_email, + claimer_user_id=None, created_on=now, ) @@ -214,6 +253,7 @@ async def new_for_device( type=InvitationType.DEVICE, created_by_user_id=author_user_id, created_by_device_id=author, + claimer_user_id=author_user_id, claimer_email=None, created_on=now, ) @@ -238,6 +278,104 @@ async def new_for_device( return token, send_email_outcome + @override + async def new_for_shamir_recovery( + self, + now: DateTime, + organization_id: OrganizationID, + author: DeviceID, + send_email: bool, + claimer_user_id: UserID, + # Only needed for testbed template + force_token: InvitationToken | None = None, + ) -> tuple[InvitationToken, None | SendEmailBadOutcome] | InviteNewForShamirBadOutcome: + try: + org = self._data.organizations[organization_id] + except KeyError: + return InviteNewForShamirBadOutcome.ORGANIZATION_NOT_FOUND + if org.is_expired: + return InviteNewForShamirBadOutcome.ORGANIZATION_EXPIRED + + async with ( + org.topics_lock(read=["common"]), + org.advisory_lock_exclusive(AdvisoryLock.InvitationCreation), + ): + try: + author_device = org.devices[author] + except KeyError: + return InviteNewForShamirBadOutcome.AUTHOR_NOT_FOUND + author_user_id = author_device.cooked.user_id + + try: + author_user = org.users[author_user_id] + except KeyError: + return InviteNewForShamirBadOutcome.AUTHOR_NOT_FOUND + if author_user.is_revoked: + return InviteNewForShamirBadOutcome.AUTHOR_REVOKED + + # Check that the claimer exists + claimer = org.users.get(claimer_user_id) + if claimer is None: + return InviteNewForShamirBadOutcome.USER_NOT_FOUND + claimer_human_handle = claimer.cooked.human_handle + + # Check that a shamir setup exists + shamir_setup = org.shamir_setup.get(claimer_user_id) + if shamir_setup is None: + # Since the author only knows about a shamir recovery if they are part of it, + # we don't have a specific error for the case where the shamir setup doesn't exist + return InviteNewForShamirBadOutcome.AUTHOR_NOT_ALLOWED + + # Author is not part of the recipients + if author_user_id not in shamir_setup.shares: + return InviteNewForShamirBadOutcome.AUTHOR_NOT_ALLOWED + + for invitation in org.invitations.values(): + if ( + force_token is None + and not invitation.is_deleted + and invitation.type == InvitationType.SHAMIR_RECOVERY + and invitation.claimer_user_id == claimer_user_id + ): + # An invitation already exists for what the user has asked for + token = invitation.token + break + + else: + # Must create a new invitation + + token = force_token or InvitationToken.new() + org.invitations[token] = MemoryInvitation( + token=token, + type=InvitationType.SHAMIR_RECOVERY, + created_by_user_id=author_user_id, + created_by_device_id=author, + claimer_email=claimer_human_handle.email, + created_on=now, + claimer_user_id=claimer_user_id, + ) + + await self._event_bus.send( + EventInvitation( + organization_id=organization_id, + token=token, + greeter=author_user_id, + status=InvitationStatus.IDLE, + ) + ) + + if send_email: + send_email_outcome = await self._send_shamir_recovery_invitation_email( + organization_id=organization_id, + email=claimer_human_handle.email, + token=token, + greeter_human_handle=author_user.cooked.human_handle, + ) + else: + send_email_outcome = None + + return token, send_email_outcome + @override async def cancel( self, @@ -312,23 +450,29 @@ async def list( items = [] for invitation in org.invitations.values(): - if invitation.created_by_user_id != author_user_id: - continue - - status = self._get_invitation_status(organization_id, invitation) match invitation.type: case InvitationType.USER: + # In the future, this might change to: + # if author_user.current_profile == UserProfile.ADMIN + # so that any admin can greet a user + if invitation.created_by_user_id != author_user_id: + continue assert invitation.claimer_email is not None + status = self._get_invitation_status(organization_id, invitation) item = UserInvitation( claimer_email=invitation.claimer_email, token=invitation.token, created_on=invitation.created_on, created_by_device_id=invitation.created_by_device_id, created_by_user_id=invitation.created_by_user_id, + # This should also change once any admin can greet a user created_by_human_handle=author_user.cooked.human_handle, status=status, ) case InvitationType.DEVICE: + if invitation.created_by_user_id != author_user_id: + continue + status = self._get_invitation_status(organization_id, invitation) item = DeviceInvitation( token=invitation.token, created_on=invitation.created_on, @@ -337,6 +481,20 @@ async def list( created_by_human_handle=author_user.cooked.human_handle, status=status, ) + case InvitationType.SHAMIR_RECOVERY: + shamir_recovery_invitation = self._get_shamir_recovery_invitation( + org, invitation + ) + # There is no corresponding setup for this invitation, ignore it + if shamir_recovery_invitation is None: + continue + # The author is not part of the recipients + if not any( + recipient.user_id == author_user_id + for recipient in shamir_recovery_invitation.recipients + ): + continue + item = shamir_recovery_invitation case unknown: # TODO: find a way to type `InvitationType` as a proper enum # so that we can use `assert_never` here @@ -385,6 +543,14 @@ async def info_as_invited( created_by_human_handle=created_by_human_handle, token=invitation.token, ) + case InvitationType.SHAMIR_RECOVERY: + shamir_recovery_invitation = self._get_shamir_recovery_invitation(org, invitation) + if shamir_recovery_invitation is None: + # TODO: The invitation is not actually deleted, but the corresponding setup has + # been deleted. This is a bit misleading, we should find a way to differentiate + # between the two cases. + return InviteAsInvitedInfoBadOutcome.INVITATION_DELETED + return shamir_recovery_invitation case unknown: assert False, unknown @@ -426,6 +592,12 @@ async def test_dump_all_invitations( token=invitation.token, ) ) + case InvitationType.SHAMIR_RECOVERY: + shamir_recovery_invitation = self._get_shamir_recovery_invitation( + org, invitation + ) + if shamir_recovery_invitation is not None: + current_user_invitations.append(shamir_recovery_invitation) case unknown: assert False, unknown diff --git a/server/parsec/components/memory/user.py b/server/parsec/components/memory/user.py index b5a4c3f48c8..07826ad6579 100644 --- a/server/parsec/components/memory/user.py +++ b/server/parsec/components/memory/user.py @@ -597,7 +597,10 @@ async def get_certificates( for user_id, shamir in sorted(org.shamir_setup.items(), key=lambda x: x[1].brief.timestamp): # filter on timestamp - if shamir_recovery_after is not None and shamir.brief.timestamp < shamir_recovery_after: + if ( + shamir_recovery_after is not None + and shamir.brief.timestamp <= shamir_recovery_after + ): continue # if it is user's certificate keep brief @@ -606,8 +609,9 @@ async def get_certificates( # if user is a share recipient keep share and brief if author_user_id in shamir.shares.keys(): - shamir_recovery_certificates.append(shamir.shares[author_user_id]) + # Important: the brief certificate must come first shamir_recovery_certificates.append(shamir.brief_bytes) + shamir_recovery_certificates.append(shamir.shares[author_user_id]) return CertificatesBundle( common=common_certificates, diff --git a/server/parsec/templates/invitation_mail.html b/server/parsec/templates/invitation_mail.html index 7f48b8a2eaa..eed34097586 100644 --- a/server/parsec/templates/invitation_mail.html +++ b/server/parsec/templates/invitation_mail.html @@ -412,12 +412,15 @@

- {% if greeter %} + {% if is_user_invitation %} You have received an invitation from {{ greeter }} to join the {{ organization_id }} organization on Parsec. - {% else %} + {% elif is_device_invitation %} You have received an invitation to add a new device to the {{ organization_id }} organization on Parsec. + {% elif is_shamir_recovery_invitation %} + You have received an invitation to start a recovery procedure on the {{ organization_id }} + organization on Parsec. {% endif %}

Your next steps:

@@ -470,11 +473,13 @@

- {% if greeter %} + {% if is_user_invitation %} 3. Get in touch with {{ greeter }} and follow the next steps on the Parsec client. - {% else %} + {% elif is_device_invitation %} 3. Start the invitation process from a device already part of the organization, then follow the steps on the Parsec client. + {% elif is_shamir_recovery_invitation %} + 3. Get in touch with {{ greeter }} and follow the next steps on the Parsec client. {% endif %}

diff --git a/server/parsec/templates/invitation_mail.txt b/server/parsec/templates/invitation_mail.txt index 4c4684898dd..34699b72e3d 100644 --- a/server/parsec/templates/invitation_mail.txt +++ b/server/parsec/templates/invitation_mail.txt @@ -1,7 +1,9 @@ -{% if greeter %} +{% if is_user_invitation %} You have received an invitation from {{ greeter }} to join the {{ organization_id }} organization on Parsec. -{% else %} +{% elif is_device_invitation %} You have received an invitation to add a new device to the {{ organization_id}} organization on Parsec. +{% elif is_shamir_recovery_invitation %} +You have received an invitation to start a recovery procedure on the {{ organization_id }} organization on Parsec. {% endif %} Your next steps: @@ -10,11 +12,13 @@ Your next steps: 2. Once installed, open the following link to proceed to Parsec: {{ invitation_url }} -{% if greeter %} +{% if is_user_invitation %} 3. Get in touch with {{ greeter }} and follow the next steps on the Parsec client. -{% else %} +{% elif is_device_invitation %} 3. Start the invitation process from a device already part of the organization, then follow the steps on the Parsec client. +{% elif is_shamir_recovery_invitation %} +3. Get in touch with {{ greeter }} and follow the next steps on the Parsec client. {% endif %} For more information please refer to Parsec documentation: https://docs.parsec.cloud diff --git a/server/src/enumerate.rs b/server/src/enumerate.rs index ce754582b1f..ae87b7bfdfa 100644 --- a/server/src/enumerate.rs +++ b/server/src/enumerate.rs @@ -32,7 +32,12 @@ crate::binding_utils::gen_py_wrapper_class_for_enum!( InvitationType, libparsec_types::InvitationType, ["DEVICE", device, libparsec_types::InvitationType::Device], - ["USER", user, libparsec_types::InvitationType::User] + ["USER", user, libparsec_types::InvitationType::User], + [ + "SHAMIR_RECOVERY", + shamir_recovery, + libparsec_types::InvitationType::ShamirRecovery + ] ); crate::binding_utils::gen_py_wrapper_class_for_enum!( diff --git a/server/src/lib.rs b/server/src/lib.rs index b3ed513b77a..02980416c2f 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -106,6 +106,7 @@ fn entrypoint(py: Python, m: Bound<'_, PyModule>) -> PyResult<()> { tm.add_class::()?; tm.add_class::()?; tm.add_class::()?; + tm.add_class::()?; tm.add_class::()?; tm.add_class::()?; tm.add_class::()?; diff --git a/server/src/testbed.rs b/server/src/testbed.rs index 0ff051c20bf..9abc8bb24f6 100644 --- a/server/src/testbed.rs +++ b/server/src/testbed.rs @@ -269,6 +269,22 @@ event_wrapper!( } ); +event_wrapper!( + TestbedEventNewShamirRecoveryInvitation, + [ + claimer: UserID, + created_by: DeviceID, + created_on: DateTime, + token: InvitationToken, + ], + |_py, x: &TestbedEventNewShamirRecoveryInvitation| -> PyResult { + Ok(format!( + "claimer={:?}, created_by={:?}, created_on={:?}, token={:?}", + x.claimer.0, x.created_by.0, x.created_on.0, x.token.0, + )) + } +); + event_wrapper!( TestbedEventNewRealm, [ @@ -837,6 +853,16 @@ fn event_to_pyobject( Some(obj.into_py(py)) } + libparsec_testbed::TestbedEvent::NewShamirRecoveryInvitation(x) => { + let obj = TestbedEventNewShamirRecoveryInvitation { + claimer: x.claimer.clone().into(), + created_by: x.created_by.clone().into(), + created_on: x.created_on.into(), + token: x.token.into(), + }; + Some(obj.into_py(py)) + } + libparsec_testbed::TestbedEvent::NewRealm(x) => { let (certificate, raw_certificate) = single_certificate!(py, x, template, RealmRole); let obj = TestbedEventNewRealm { diff --git a/server/tests/api_v4/authenticated/__init__.py b/server/tests/api_v4/authenticated/__init__.py index da849499fb8..35932294425 100644 --- a/server/tests/api_v4/authenticated/__init__.py +++ b/server/tests/api_v4/authenticated/__init__.py @@ -33,3 +33,4 @@ from .test_invite_greeter_step import * # noqa from .test_invite_complete import * # noqa from .test_list_frozen_users import * # noqa +from .test_invite_new_shamir_recovery import * # noqa diff --git a/server/tests/api_v4/authenticated/test_invite_list.py b/server/tests/api_v4/authenticated/test_invite_list.py index 25a97c0882d..98e49008f12 100644 --- a/server/tests/api_v4/authenticated/test_invite_list.py +++ b/server/tests/api_v4/authenticated/test_invite_list.py @@ -1,7 +1,36 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -from parsec._parsec import DateTime, InvitationStatus, authenticated_cmds -from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, MinimalorgRpcClients + +from parsec._parsec import ( + DateTime, + InvitationStatus, + authenticated_cmds, +) +from tests.common import ( + Backend, + CoolorgRpcClients, + HttpCommonErrorsTester, + MinimalorgRpcClients, + ShamirOrgRpcClients, +) + + +async def test_authenticated_invite_list_ok_with_shamir_recovery( + shamirorg: ShamirOrgRpcClients, + backend: Backend, +) -> None: + expected_invitations = [ + authenticated_cmds.v4.invite_list.InviteListItemShamirRecovery( + created_on=shamirorg.shamir_invited_alice.event.created_on, + status=InvitationStatus.IDLE, + claimer_user_id=shamirorg.alice.user_id, + token=shamirorg.shamir_invited_alice.token, + ) + ] + + rep = await shamirorg.bob.invite_list() + assert isinstance(rep, authenticated_cmds.v4.invite_list.RepOk) + assert rep.invitations == expected_invitations async def test_authenticated_invite_list_ok( @@ -49,8 +78,8 @@ async def test_authenticated_invite_list_ok( # t3 = DateTime(2020, 1, 3) # outcome = await backend.invite.new_for_user( # now=t3, - # organization_id=coolorg.organization_id, - # author=coolorg.alice.user_id, + # organization_id=shamirorg.organization_id, + # author=shamirorg.alice.user_id, # claimer_email="zack@example.invalid", # send_email=False, # ) diff --git a/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py b/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py new file mode 100644 index 00000000000..e83393793d9 --- /dev/null +++ b/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py @@ -0,0 +1,208 @@ +# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +from unittest.mock import ANY + +import pytest + +from parsec._parsec import ( + InvitationStatus, + UserID, + authenticated_cmds, +) +from parsec.components.invite import ( + SendEmailBadOutcome, + ShamirRecoveryInvitation, + ShamirRecoveryRecipient, +) +from parsec.events import EventInvitation +from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients + + +@pytest.mark.parametrize("send_email", (False, True)) +async def test_authenticated_invite_new_shamir_recovery_ok_new( + send_email: bool, + shamirorg: ShamirOrgRpcClients, + backend: Backend, +) -> None: + expected_invitations = await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + + with backend.event_bus.spy() as spy: + rep = await shamirorg.mike.invite_new_shamir_recovery( + send_email=send_email, + claimer_user_id=shamirorg.mallory.user_id, + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepOk) + assert ( + rep.email_sent + == authenticated_cmds.v4.invite_new_shamir_recovery.InvitationEmailSentStatus.SUCCESS + ) + invitation_token = rep.token + + await spy.wait_event_occurred( + EventInvitation( + organization_id=shamirorg.organization_id, + greeter=shamirorg.mike.user_id, + token=invitation_token, + status=InvitationStatus.IDLE, + ) + ) + + expected_invitations[shamirorg.mike.user_id] = [ + ShamirRecoveryInvitation( + token=invitation_token, + created_on=ANY, + created_by_device_id=shamirorg.mike.device_id, + created_by_user_id=shamirorg.mike.user_id, + created_by_human_handle=shamirorg.mike.human_handle, + status=InvitationStatus.IDLE, + threshold=1, + claimer_user_id=shamirorg.mallory.user_id, + claimer_human_handle=shamirorg.mallory.human_handle, + recipients=[ + ShamirRecoveryRecipient( + user_id=shamirorg.mike.user_id, + human_handle=shamirorg.mike.human_handle, + shares=1, + ), + ], + ) + ] + assert ( + await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + == expected_invitations + ) + + +@pytest.mark.parametrize("send_email", (False, True)) +async def test_authenticated_invite_new_shamir_recovery_author_not_allowed( + send_email: bool, shamirorg: ShamirOrgRpcClients, backend: Backend +) -> None: + # Shamir setup exists but author is not part of the recipients + rep = await shamirorg.alice.invite_new_shamir_recovery( + send_email=send_email, + claimer_user_id=shamirorg.alice.user_id, + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepAuthorNotAllowed) + + # No shamir setup have been created + rep = await shamirorg.alice.invite_new_shamir_recovery( + send_email=send_email, + claimer_user_id=shamirorg.mike.user_id, + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepAuthorNotAllowed) + + +@pytest.mark.parametrize("send_email", (False, True)) +async def test_authenticated_invite_new_shamir_recovery_user_not_found( + send_email: bool, shamirorg: ShamirOrgRpcClients, backend: Backend +) -> None: + # Shamir setup exists but author is not part of the recipients + rep = await shamirorg.alice.invite_new_shamir_recovery( + send_email=send_email, + claimer_user_id=UserID.new(), + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepUserNotFound) + + +async def test_authenticated_invite_new_shamir_recovery_ok_already_exist( + shamirorg: ShamirOrgRpcClients, backend: Backend +) -> None: + expected_invitations = await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + + with backend.event_bus.spy() as spy: + rep = await shamirorg.bob.invite_new_shamir_recovery( + send_email=False, + claimer_user_id=shamirorg.alice.user_id, + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepOk) + assert rep.token == shamirorg.shamir_invited_alice.token + assert not spy.events + + assert ( + await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + == expected_invitations + ) + + +@pytest.mark.parametrize( + "bad_outcome", + ( + SendEmailBadOutcome.BAD_SMTP_CONFIG, + SendEmailBadOutcome.RECIPIENT_REFUSED, + SendEmailBadOutcome.SERVER_UNAVAILABLE, + ), +) +async def test_authenticated_invite_new_shamir_recovery_send_email_bad_outcome( + shamirorg: ShamirOrgRpcClients, + backend: Backend, + bad_outcome: SendEmailBadOutcome, + monkeypatch, +) -> None: + async def _mocked_send_email(*args, **kwargs): + return bad_outcome + + monkeypatch.setattr("parsec.components.invite.send_email", _mocked_send_email) + + expected_invitations = await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + + with backend.event_bus.spy() as spy: + rep = await shamirorg.mike.invite_new_shamir_recovery( + send_email=True, claimer_user_id=shamirorg.mallory.user_id + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepOk) + invitation_token = rep.token + + match bad_outcome: + case SendEmailBadOutcome.BAD_SMTP_CONFIG: + expected_email_sent_status = authenticated_cmds.v4.invite_new_shamir_recovery.InvitationEmailSentStatus.SERVER_UNAVAILABLE + case SendEmailBadOutcome.RECIPIENT_REFUSED: + expected_email_sent_status = authenticated_cmds.v4.invite_new_shamir_recovery.InvitationEmailSentStatus.RECIPIENT_REFUSED + case SendEmailBadOutcome.SERVER_UNAVAILABLE: + expected_email_sent_status = authenticated_cmds.v4.invite_new_shamir_recovery.InvitationEmailSentStatus.SERVER_UNAVAILABLE + assert rep.email_sent == expected_email_sent_status + + await spy.wait_event_occurred( + EventInvitation( + organization_id=shamirorg.organization_id, + greeter=shamirorg.mike.user_id, + token=invitation_token, + status=InvitationStatus.IDLE, + ) + ) + + expected_invitations[shamirorg.mike.user_id] = [ + ShamirRecoveryInvitation( + token=invitation_token, + created_on=ANY, + created_by_device_id=shamirorg.mike.device_id, + created_by_user_id=shamirorg.mike.user_id, + created_by_human_handle=shamirorg.mike.human_handle, + status=InvitationStatus.IDLE, + threshold=1, + claimer_user_id=shamirorg.mallory.user_id, + claimer_human_handle=shamirorg.mallory.human_handle, + recipients=[ + ShamirRecoveryRecipient( + user_id=shamirorg.mike.user_id, + human_handle=shamirorg.mike.human_handle, + shares=1, + ), + ], + ) + ] + assert ( + await backend.invite.test_dump_all_invitations(shamirorg.organization_id) + == expected_invitations + ) + + +async def test_authenticated_invite_new_shamir_recovery_http_common_errors( + coolorg: CoolorgRpcClients, authenticated_http_common_errors_tester: HttpCommonErrorsTester +) -> None: + async def do(): + await coolorg.alice.invite_new_shamir_recovery( + send_email=False, + claimer_user_id=coolorg.bob.user_id, + ) + + await authenticated_http_common_errors_tester(do) diff --git a/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py b/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py index ca27df00f81..75f7a43f153 100644 --- a/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py +++ b/server/tests/api_v4/authenticated/test_shamir_recovery_setup.py @@ -89,10 +89,6 @@ async def test_authenticated_shamir_recovery_setup_share_inconsistent_timestamp( @pytest.mark.xfail( reason="TODO: currently there is a unique shamir topic, we should switch to a per-user shamir topic instead" ) -# Cannot use `with_postgresql` fixture since `shamirorg` init uses fonctions non-implemented in postgresql -@pytest.mark.skipif( - "bool(config.getoption('--postgresql'))", reason="TODO: postgre not implemented yet" -) async def test_authenticated_shamir_recovery_setup_shamir_setup_already_exists( shamirorg: ShamirOrgRpcClients, ) -> None: @@ -299,10 +295,6 @@ async def test_authenticated_shamir_recovery_setup_missing_share_for_recipient( @pytest.mark.parametrize("kind", ("from_recipient", "from_author")) @pytest.mark.usefixtures("ballpark_always_ok") -# Cannot use `with_postgresql` fixture since `shamirorg` init uses fonctions non-implemented in postgresql -@pytest.mark.skipif( - "bool(config.getoption('--postgresql'))", reason="TODO: postgre not implemented yet" -) async def test_authenticated_shamir_recovery_setup_require_greater_timestamp( shamirorg: ShamirOrgRpcClients, kind: str ) -> None: @@ -349,10 +341,6 @@ async def test_authenticated_shamir_recovery_setup_require_greater_timestamp( reason="TODO: currently there is a unique shamir topic, we should switch to a per-user shamir topic instead" ) @pytest.mark.usefixtures("ballpark_always_ok") -# Cannot use `with_postgresql` fixture since `shamirorg` init uses fonctions non-implemented in postgresql -@pytest.mark.skipif( - "bool(config.getoption('--postgresql'))", reason="TODO: postgre not implemented yet" -) async def test_authenticated_shamir_recovery_setup_isolated_from_other_users( shamirorg: ShamirOrgRpcClients, ) -> None: diff --git a/server/tests/api_v4/invited/test_invite_info.py b/server/tests/api_v4/invited/test_invite_info.py index 31f7d3492cf..851f94a8322 100644 --- a/server/tests/api_v4/invited/test_invite_info.py +++ b/server/tests/api_v4/invited/test_invite_info.py @@ -3,7 +3,7 @@ import pytest from parsec._parsec import invited_cmds -from tests.common import CoolorgRpcClients, HttpCommonErrorsTester +from tests.common import CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients @pytest.mark.parametrize("user_or_device", ("user", "device")) @@ -32,6 +32,34 @@ async def test_invited_invite_info_ok(user_or_device: str, coolorg: CoolorgRpcCl assert False, unknown +async def test_invited_invite_info_ok_with_shamir(shamirorg: ShamirOrgRpcClients) -> None: + rep = await shamirorg.shamir_invited_alice.invite_info() + assert rep == invited_cmds.v4.invite_info.RepOk( + invited_cmds.v4.invite_info.UserOrDeviceShamirRecovery( + claimer_user_id=shamirorg.alice.user_id, + claimer_human_handle=shamirorg.alice.human_handle, + threshold=2, + recipients=[ + invited_cmds.latest.invite_info.ShamirRecoveryRecipient( + user_id=shamirorg.bob.user_id, + human_handle=shamirorg.bob.human_handle, + shares=2, + ), + invited_cmds.latest.invite_info.ShamirRecoveryRecipient( + user_id=shamirorg.mallory.user_id, + human_handle=shamirorg.mallory.human_handle, + shares=1, + ), + invited_cmds.latest.invite_info.ShamirRecoveryRecipient( + user_id=shamirorg.mike.user_id, + human_handle=shamirorg.mike.human_handle, + shares=1, + ), + ], + ) + ) + + async def test_invited_invite_info_http_common_errors( coolorg: CoolorgRpcClients, invited_http_common_errors_tester: HttpCommonErrorsTester ) -> None: diff --git a/server/tests/common/client.py b/server/tests/common/client.py index 1f5073595e3..ea452231d40 100644 --- a/server/tests/common/client.py +++ b/server/tests/common/client.py @@ -180,7 +180,9 @@ def __init__( self, raw_client: AsyncClient, organization_id: OrganizationID, - event: tb.TestbedEventNewUserInvitation | tb.TestbedEventNewDeviceInvitation, + event: tb.TestbedEventNewUserInvitation + | tb.TestbedEventNewDeviceInvitation + | tb.TestbedEventNewShamirRecoveryInvitation, ): self.raw_client = raw_client self.organization_id = organization_id @@ -472,6 +474,7 @@ class ShamirOrgRpcClients: Mallory & mike with 1 share each) - Bob has a deleted shamir recovery (used to be threshold: 1, recipients: Alice & Mallory) - Mallory has a shamir recovery setup (threshold: 1, recipients: only Mike) + - Bob has invited Alice to do a Shamir recovery - devices `alice@dev1`/`bob@dev1`/`mallory@dev1`/`mike@dev1` starts with up-to-date storages - devices `alice@dev2` and `bob@dev2` whose storages are empty @@ -486,6 +489,7 @@ class ShamirOrgRpcClients: _bob: AuthenticatedRpcClient | None = None _mallory: AuthenticatedRpcClient | None = None _mike: AuthenticatedRpcClient | None = None + _shamir_invited_alice: InvitedRpcClient | None = None @property def anonymous(self) -> AnonymousRpcClient: @@ -522,6 +526,23 @@ def mike(self) -> AuthenticatedRpcClient: self._mike = self._init_for("mike") return self._mike + @property + def shamir_invited_alice(self) -> InvitedRpcClient: + if self._shamir_invited_alice: + return self._shamir_invited_alice + + for event in self.testbed_template.events: + if ( + isinstance(event, tb.TestbedEventNewShamirRecoveryInvitation) + and event.claimer == self.alice.user_id + ): + self._shamir_invited_alice = InvitedRpcClient( + self.raw_client, self.organization_id, event=event + ) + return self._shamir_invited_alice + else: + raise RuntimeError("Zack user invitation event not found !") + @property def root_signing_key(self) -> SigningKey: for event in self.testbed_template.events: @@ -554,7 +575,7 @@ def bob_remove_certificate(self) -> ShamirRecoveryDeletionCertificate: and event.setup_to_delete_user_id == user_id ): return event.certificate - raise RuntimeError("New shamir recovery event not found for user `bob` !") + raise RuntimeError("Remove shamir recovery event not found for user `bob` !") @property def mallory_brief_certificate(self) -> ShamirRecoveryBriefCertificate: @@ -644,7 +665,7 @@ def _init_for(self, user: str) -> AuthenticatedRpcClient: @pytest.fixture async def shamirorg( - app: AsgiApp, testbed: TestbedBackend + app: AsgiApp, testbed: TestbedBackend, skip_if_postgresql: None ) -> AsyncGenerator[ShamirOrgRpcClients, None]: async with AsyncClient(app=app) as raw_client: organization_id, _, template_content = await testbed.new_organization("shamir") diff --git a/server/tests/common/rpc.py b/server/tests/common/rpc.py index c1089461948..b822c41e1a2 100644 --- a/server/tests/common/rpc.py +++ b/server/tests/common/rpc.py @@ -188,6 +188,15 @@ async def invite_new_device( raw_rep = await self._do_request(req.dump(), "authenticated") return authenticated_cmds.latest.invite_new_device.Rep.load(raw_rep) + async def invite_new_shamir_recovery( + self, claimer_user_id: UserID, send_email: bool + ) -> authenticated_cmds.latest.invite_new_shamir_recovery.Rep: + req = authenticated_cmds.latest.invite_new_shamir_recovery.Req( + claimer_user_id=claimer_user_id, send_email=send_email + ) + raw_rep = await self._do_request(req.dump(), "authenticated") + return authenticated_cmds.latest.invite_new_shamir_recovery.Rep.load(raw_rep) + async def invite_new_user( self, claimer_email: str, send_email: bool ) -> authenticated_cmds.latest.invite_new_user.Rep: