diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d8f95..ee82b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,12 @@ breaking changes. Cyze is beta, unaudited software (see the README). - **Read-only access:** **Share read-only access** on a group's page gives an accountant or auditor the viewing key, once you confirm what it reveals. The key no longer shows under Keys and addresses. +- **Share repair:** a member who lost their device and backup gets their share + back from as many members as the threshold (FROST's repairable threshold + scheme), without moving the funds. **Recover a group** on the new device, + **Repair a member's share** on the group's page, approval under Approvals. + The rebuilt share is checked against the group's record before it's kept, + and the group switches to the member's new key once enough helpers vouch. - **Reasons:** say what a payment is for, for the group only. - Approvers see it with the request, attributed to the member who asked. - Once sent, every member's **Activity** shows it. Any member can add diff --git a/PRIVACY.md b/PRIVACY.md index c38ad10..adf21a7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -81,6 +81,9 @@ traffic and sees the same kind of connection details. - **Backups** are files you save where you choose, encrypted like the keystore. - **History exports** (Export CSV) are files you save where you choose, and aren't encrypted: they list the wallet's payments, addresses and memos. +- **Repairing a share** sends the repaired member's new device the group's + public data and its viewing seed, end-to-end encrypted through the + coordination server, from each helper. - **A viewing key you share** (Share read-only access) lets whoever has it see the wallet's balance and every payment, past and future. It can't be taken back. diff --git a/TODO.md b/TODO.md index 9ca1b48..d84052a 100644 --- a/TODO.md +++ b/TODO.md @@ -132,8 +132,11 @@ and skip kinds they don't know. committed in the signing package (`AwaitingApproval.signers`). - [ ] **Change members or threshold** — a new group, then move the funds (reusing the viewing-key move, `commands/viewing.rs`). -- [ ] **Share repair** — a member who lost their device gets a new share from - the others (FROST repairable shares), without moving funds. +- [x] **Share repair** — `core/src/repair.rs` (RTS over a `REPAIR_SESSION`), + `commands/repair.rs`; helpers vouch for the new key (`MemberKey` + records, `polls::vouched_member_keys`) so other members switch too. + Follow-up: share *refresh* (new shares, same key) for a device that may + have been stolen. - [ ] **Recurring payments** — scheduled payments that still need approval. ## Voting (protocol / coinholder governance) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2ab0f77..cfc3006 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -112,6 +112,39 @@ then unlock with your passphrase, or with your recovery code if you've forgotten it. Without a backup file, the recovery code alone can't bring back a lost device's key shares. +### Repairing a lost share + +If you lost your device and have no backup, the other members can **repair** +your share: as many of them as the group's threshold rebuild it on your new +device, without learning it and without moving the funds. A group where every +member must approve (2 of 2, 3 of 3) can't repair a share. + +**On your new device:** +1. Set up Cyze (a new keystore), then open **Groups → Recover a group**. +2. Send a member your **contact code**, and read them its **safety code** on a + call or in person, so they know the new key is yours. +3. Paste the **repair code** they send back and click **Recover**. Keep Cyze + open until the helpers have approved; the group then appears under Groups, + its wallet syncing from where it started. + +**As a helper:** +1. On the group's page, open **Recovery & share repair → Repair a member's + share**, choose the member, and paste their new contact code. +2. Read its safety code with them and tick that it matches. Choose the helpers + (at least the threshold, you included), then **Start repair**, and send the + member the repair code it shows. +3. The other helpers get the request under **Approvals**, with the same safety + code to compare, and click **Approve and help**. + +Your app checks the rebuilt share against the group's public record of it +before keeping it. Afterwards the group uses your new key: the helpers' apps at +once, and the other members' apps once as many helpers vouch for it as a +payment needs. + +**Lost or stolen?** Repair rebuilds the *same* share. If your old device might +have been stolen, whoever has it still holds the share: don't repair, form a +new group and move the funds instead. + --- ## 3. Managing contacts diff --git a/src-tauri/core/src/dkg.rs b/src-tauri/core/src/dkg.rs index a869b49..bca089c 100644 --- a/src-tauri/core/src/dkg.rs +++ b/src-tauri/core/src/dkg.rs @@ -391,7 +391,7 @@ pub(crate) struct SessionIo<'a> { } impl SessionIo<'_> { - async fn send_to(&mut self, recipients: &[PublicKey], bytes: &[u8]) -> Result<(), CoreError> { + pub(crate) async fn send_to(&mut self, recipients: &[PublicKey], bytes: &[u8]) -> Result<(), CoreError> { for pk in recipients { let msg = self.cipher.encrypt(Some(pk), bytes.to_vec()).map_err(|e| CoreError::Crypto(e.to_string()))?; self.client.send(&api::SendArgs { session_id: self.session_id, recipients: vec![pk.clone()], msg }).await?; @@ -400,7 +400,7 @@ impl SessionIo<'_> { } /// Wait for more messages (or cancellation). - async fn receive_more(&mut self) -> Result<(), CoreError> { + pub(crate) async fn receive_more(&mut self) -> Result<(), CoreError> { let r = self.client.receive(&api::ReceiveArgs { session_id: self.session_id, as_coordinator: false }).await?; if r.msgs.is_empty() { tokio::select! { diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 7447139..5aa746d 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -18,6 +18,7 @@ pub mod neterr; pub mod payees; pub mod polls; pub mod redact; +pub mod repair; pub mod rules; pub mod safety; pub mod sealed; diff --git a/src-tauri/core/src/polls.rs b/src-tauri/core/src/polls.rs index 6bb7c9b..043ff43 100644 --- a/src-tauri/core/src/polls.rs +++ b/src-tauri/core/src/polls.rs @@ -125,6 +125,11 @@ pub enum RecordBody { Payee { payee_id: String, name: String, address: String }, /// The author removing a payee they added. PayeeRemoved { payee_id: String }, + /// The author, having helped repair a member's share + /// ([`crate::repair`]), vouching that the member's communication key is + /// now `pubkey`. Other members take it once as many members vouch for it as + /// a payment needs approvals. + MemberKey { identifier: String, pubkey: String }, /// What a payment was for, as the author tells the group: their latest /// for the transaction counts, and an empty one takes theirs back. Never /// on chain. @@ -225,6 +230,12 @@ impl Record { return bad("payee id"); } } + RecordBody::MemberKey { identifier, pubkey } => { + let hex_of = |s: &str, max: usize| !s.is_empty() && s.len() <= max && s.chars().all(|c| c.is_ascii_hexdigit()); + if !hex_of(identifier, 128) || pubkey.len() != 64 || !hex_of(pubkey, 64) { + return bad("member key"); + } + } RecordBody::PaymentReason { txid, text } => { if txid.len() != 64 || !txid.chars().all(|c| c.is_ascii_hexdigit()) { return bad("transaction id"); @@ -476,6 +487,33 @@ pub fn polls(group_id: &str, log: &GroupLog, members: &[String], threshold: u32, out } +/// Members' new communication keys that as many current members as +/// `threshold` vouch for ([`RecordBody::MemberKey`], each member's latest for +/// a member counting): identifier (hex) to the key (hex). +pub fn vouched_member_keys(log: &GroupLog, members: &[String], threshold: u32) -> BTreeMap { + let current: HashSet<&str> = members.iter().map(String::as_str).collect(); + // identifier → voucher → key, each voucher's latest. + let mut latest: BTreeMap<&str, BTreeMap<&str, &str>> = BTreeMap::new(); + for (author, records) in log.records.iter().filter(|(a, _)| current.contains(a.as_str())) { + for record in records.values().filter(|r| r.check().is_ok()) { + if let RecordBody::MemberKey { identifier, pubkey } = &record.body { + latest.entry(identifier).or_default().insert(author, pubkey); + } + } + } + let mut out = BTreeMap::new(); + for (identifier, vouchers) in latest { + let mut counts: BTreeMap<&str, u32> = BTreeMap::new(); + for key in vouchers.values() { + *counts.entry(key).or_default() += 1; + } + if let Some((key, _)) = counts.into_iter().find(|(_, n)| threshold > 0 && *n >= threshold) { + out.insert(identifier.to_string(), key.to_string()); + } + } + out +} + /// The poll whose rules are in effect: the latest one the group agreed to /// adopt. None if the group never adopted any, so it has none. pub fn rules_in_effect(polls: &[PollView]) -> Option<&PollView> { @@ -718,6 +756,20 @@ mod tests { assert_eq!(log.received_at("alice", &rec(1, 100, vote("p", 0))), 900); } + #[test] + fn a_members_new_key_counts_once_enough_members_vouch_for_it() { + let (new, other) = ("aa".repeat(32), "bb".repeat(32)); + let vouch = |key: &str| RecordBody::MemberKey { identifier: "03".into(), pubkey: key.into() }; + let mut log = GroupLog::default(); + log.insert("alice", rec(1, 10, vouch(&new))); + log.insert("mallory", rec(1, 11, vouch(&new))); + assert!(vouched_member_keys(&log, &members(), 2).is_empty(), "one member, and an outsider"); + log.insert("bob", rec(1, 12, vouch(&other))); + assert!(vouched_member_keys(&log, &members(), 2).is_empty(), "two members, two keys"); + log.insert("bob", rec(2, 13, vouch(&new))); + assert_eq!(vouched_member_keys(&log, &members(), 2), BTreeMap::from([("03".to_string(), new)])); + } + fn rules_poll(id: &str, rules: crate::rules::GroupRules) -> RecordBody { RecordBody::Poll { poll_id: id.into(), diff --git a/src-tauri/core/src/repair.rs b/src-tauri/core/src/repair.rs new file mode 100644 index 0000000..7231c0e --- /dev/null +++ b/src-tauri/core/src/repair.rs @@ -0,0 +1,672 @@ +//! Repairing a member's lost key share with the help of others, using FROST's +//! repairable threshold scheme (`frost_core::keys::repairable`): as many +//! helpers as the group's threshold rebuild the member's share for them, +//! without any of them learning it, and without moving the group's funds. +//! +//! A member who lost their device lost their communication key too, so they +//! come back with a new one, and the helpers accept it as theirs. That is the +//! trust decision: helpers should compare the new key's safety code with the +//! person first. As many members as can spend have to agree, the same trust +//! the wallet rests on. +//! +//! Repair rebuilds the *same* share. If a device may have been stolen rather +//! than lost, repairing doesn't shut the thief out: the group should move its +//! funds to a new group instead. +//! +//! The exchange, over a session marked [`REPAIR_SESSION`] holding the helpers +//! and the member's new key: +//! 1. The helper who starts sends everyone a [`RepairMsg::Request`]: which +//! member, their new key, and the helpers. +//! 2. Each helper sends every other helper a [`RepairMsg::Delta`] (step 1). +//! 3. Each helper sums what it got and sends the member a +//! [`RepairMsg::Share`] (step 2) with the group's data: what the member's +//! app needs to rejoin, its viewing seed included. +//! 4. The member's app sums those (step 3), checks the share against the +//! group's public verifying share for them, and answers [`RepairMsg::Done`]. + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; + +use frost_client::api::{self, PublicKey, Uuid}; +use frost_client::cipher::{Cipher, PrivateKey}; +use frost_core::keys::{repairable, KeyPackage, PublicKeyPackage, SecretShare, VerifiableSecretSharingCommitment, VerifyingShare}; +use frost_core::{Ciphersuite, Identifier, Scalar}; +use frost_ed25519::Ed25519Sha512; +use rand::rngs::OsRng; +use reddsa::frost::redpallas::PallasBlake2b512; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use crate::error::CoreError; +use crate::transport::{FrostdClient, ServerTrust}; + +/// The `message_count` marking a session that repairs a member's share (see +/// [`crate::viewing::NEW_VIEWING_KEY_SESSION`] for why sessions are marked). +pub const REPAIR_SESSION: u8 = 0xCA; + +/// How long a repair waits for everyone before giving up. +pub const REPAIR_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); + +/// What the member being repaired pastes into their new app: where the +/// repair runs, and what it's for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepairCode { + /// `host:port` of the frostd server, and its certificate when it isn't + /// publicly trusted. + pub server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert_pem: Option, + pub session_id: Uuid, + /// The group's name and the member's, to show before joining. + pub group: String, + pub member: String, +} + +const CODE_PREFIX: &str = "cyzerepair1"; +const MAX_CODE_LEN: usize = 16 * 1024; + +impl RepairCode { + pub fn encode(&self) -> Result { + use base64::Engine as _; + let json = serde_json::to_vec(self).map_err(|e| CoreError::Config(e.to_string()))?; + Ok(format!("{CODE_PREFIX}{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))) + } + + /// Read a repair code as pasted: surrounding text and line breaks a chat + /// app added are ignored. + pub fn decode(text: &str) -> Result { + use base64::Engine as _; + let invalid = || CoreError::Config(format!("this isn't a repair code: it should start with {CODE_PREFIX}")); + if text.len() > MAX_CODE_LEN { + return Err(invalid()); + } + let start = text.find(CODE_PREFIX).ok_or_else(invalid)?; + // A wrapped code arrives in whitespace-separated pieces, possibly + // followed by more words; use the longest run of pieces that decodes. + let rest = &text[start + CODE_PREFIX.len()..]; + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c.is_whitespace())) + .unwrap_or(rest.len()); + let pieces: Vec<&str> = rest[..end].split_whitespace().collect(); + let code = (1..=pieces.len()) + .rev() + .find_map(|n| { + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(pieces[..n].concat()).ok()?; + serde_json::from_slice::(&json).ok() + }) + .ok_or_else(invalid)?; + if code.server.trim().is_empty() || code.server.contains(['/', ' ', '@']) { + return Err(CoreError::Config("this repair code's server address is malformed".into())); + } + Ok(code) + } +} + +/// What a repaired member's app needs of the group besides their share: what +/// its other members keep. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupData { + pub group_id: String, + pub description: String, + pub ciphersuite: String, + pub threshold: u16, + /// The public key package, hex. + pub public_key_package: String, + pub server_url: Option, + /// The members: identifier (hex) to communication key (hex), the repaired + /// member's new key included. + pub members: BTreeMap, + /// The group's viewing seed (hex), for a wallet group that has one. + #[serde(default)] + pub viewing_seed: Option, + /// Where the group's wallet starts scanning, by network, so a repaired + /// member's wallet doesn't miss what came before. + #[serde(default)] + pub birthdays: BTreeMap, +} + +/// A message of a repair. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "cyze_repair", rename_all = "snake_case")] +enum RepairMsg { + /// What's being repaired, from the helper who started. + Request { + group_id: String, + /// The member's identifier (hex), and their old and new keys (hex). + identifier: String, + member: String, + new_key: String, + /// Every helper's communication key (hex). + helpers: Vec, + }, + /// Step 1, for one other helper. + Delta { value: String }, + /// Step 2, for the member, with the group's data. + Share { group: Box, sigma: String }, + /// The member's app checked the rebuilt share. + Done { ok: bool }, +} + +/// Progress of a repair, for either side. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "phase", rename_all = "snake_case")] +pub enum RepairEvent { + Connecting, + SessionReady { session_id: Uuid }, + /// What a helper is asked to help with, awaiting their approval. + AwaitingApproval { group_id: String, identifier: String, member: String, new_key: String }, + /// These (hex communication keys) haven't sent their part yet. + Waiting { members: Vec }, + /// This helper sent the member its part. + ShareSent, +} + +/// Step 1 for helper `key_package`: what to send each helper (this one +/// included) to help repair `participant`'s share. +pub fn deltas( + helpers: &[Identifier], + key_package: &KeyPackage, + participant: Identifier, +) -> Result, Scalar>, CoreError> { + // Step 1 reads only the share's identifier and value. + let commitment = VerifiableSecretSharingCommitment::::deserialize(Vec::>::new()) + .map_err(|e| CoreError::Ceremony(e.to_string()))?; + let share = SecretShare::new(*key_package.identifier(), *key_package.signing_share(), commitment); + repairable::repair_share_step_1(helpers, &share, &mut OsRng, participant).map_err(|e| CoreError::Ceremony(e.to_string())) +} + +/// Step 3: the member's key package from every helper's step-2 sum, checked +/// against the group's public verifying share for them. Err if it doesn't +/// match, as it won't if any helper sent something wrong. +pub fn rebuild( + sigmas: &[Scalar], + identifier: Identifier, + public_key_package: &PublicKeyPackage, + threshold: u16, +) -> Result, CoreError> { + let commitment = VerifiableSecretSharingCommitment::::deserialize(Vec::>::new()) + .map_err(|e| CoreError::Ceremony(e.to_string()))?; + let share = repairable::repair_share_step_3(sigmas, identifier, &commitment); + let verifying_share = VerifyingShare::from(*share.signing_share()); + let expected = public_key_package + .verifying_shares() + .get(&identifier) + .ok_or_else(|| CoreError::Ceremony("the group has no member with that identifier".into()))?; + if verifying_share != *expected { + return Err(CoreError::Ceremony( + "the rebuilt share doesn't match the group's record of it, so it wasn't saved".into(), + )); + } + Ok(KeyPackage::new(identifier, *share.signing_share(), verifying_share, *public_key_package.verifying_key(), threshold)) +} + +fn scalar_hex(s: &Scalar) -> String { + hex::encode(<::Field as frost_core::Field>::serialize(s).as_ref()) +} + +fn scalar_from_hex(text: &str) -> Result, CoreError> { + let bad = || CoreError::Ceremony("a helper sent a malformed value".into()); + let bytes = hex::decode(text).map_err(|_| bad())?; + let serialized = bytes.try_into().map_err(|_| bad())?; + <::Field as frost_core::Field>::deserialize(&serialized).map_err(|_| bad()) +} + +fn identifier_from_hex(text: &str) -> Result, CoreError> { + let bytes = hex::decode(text).map_err(|_| CoreError::Ceremony("a malformed identifier".into()))?; + Identifier::::deserialize(&bytes).map_err(|e| CoreError::Ceremony(e.to_string())) +} + +fn pubkey_hex(p: &PublicKey) -> String { + hex::encode(&p.0) +} + +/// Where a repair runs, and who this device is in it. +pub struct Connection { + /// `host:port` of the frostd server. + pub server_url: String, + pub trust: ServerTrust, + pub comm_privkey: PrivateKey, + pub comm_pubkey: PublicKey, +} + +/// Start a repair session: the helpers and the member's new key in it. +pub async fn create_session(conn: &Connection, helpers: &[PublicKey], new_key: &PublicKey) -> Result { + let mut client = FrostdClient::new(format!("https://{}", conn.server_url), &conn.trust)?; + client.login(&conn.comm_privkey, &conn.comm_pubkey).await?; + let mut pubkeys = helpers.to_vec(); + pubkeys.push(new_key.clone()); + let session_id = + client.create_new_session(&api::CreateNewSessionArgs { pubkeys, message_count: REPAIR_SESSION }).await; + let _ = client.logout().await; + Ok(session_id?.session_id) +} + +/// A helper's part in a repair. +pub struct HelperParams { + pub conn: Connection, + pub session_id: Uuid, + /// This group as this helper keeps it, its key package included. For the + /// helper who started the session, `start` says what to repair. + pub group: GroupData, + pub key_package: Vec, + pub start: Option, +} + +/// What the helper who starts a repair asks for. +pub struct Start { + /// The member's identifier (hex) and new key. + pub identifier: String, + pub new_key: PublicKey, + /// Every helper, this one included. + pub helpers: Vec, +} + +/// How a repair went, for a helper: the member's identifier and new key, and +/// whether their app checked the rebuilt share. +#[derive(Debug, Clone)] +pub struct HelperOutcome { + pub identifier: String, + pub new_key: PublicKey, +} + +/// Take a helper's part. A helper who didn't start the session is shown the +/// request (an [`RepairEvent::AwaitingApproval`]) and goes on only once +/// `approval` says yes. Returns once the member's app confirmed the share. +pub async fn help( + params: HelperParams, + events: mpsc::Sender, + approval: oneshot::Receiver, + cancel: CancellationToken, +) -> Result { + match params.group.ciphersuite.as_str() { + id if id == Ed25519Sha512::ID => help_generic::(params, events, approval, cancel).await, + id if id == PallasBlake2b512::ID => help_generic::(params, events, approval, cancel).await, + other => Err(CoreError::Config(format!("can't repair a {other} group"))), + } +} + +async fn help_generic( + params: HelperParams, + events: mpsc::Sender, + approval: oneshot::Receiver, + cancel: CancellationToken, +) -> Result { + let conn = ¶ms.conn; + let key_package = KeyPackage::::deserialize(¶ms.key_package).map_err(|e| CoreError::Ceremony(e.to_string()))?; + let members: HashMap = params + .group + .members + .iter() + .map(|(id, key)| Ok((id.clone(), PublicKey(hex::decode(key).map_err(|_| CoreError::Config("a malformed member key".into()))?)))) + .collect::>()?; + let member_keys: HashSet<&PublicKey> = members.values().collect(); + + let mut client = FrostdClient::new(format!("https://{}", conn.server_url), &conn.trust)?; + let _ = events.send(RepairEvent::Connecting).await; + client.login(&conn.comm_privkey, &conn.comm_pubkey).await?; + let session_id = params.session_id; + let result = async { + let info = client.get_session_info(&api::GetSessionInfoArgs { session_id }).await?; + if info.message_count != REPAIR_SESSION { + return Err(CoreError::Ceremony("this session isn't for repairing a share".into())); + } + let _ = events.send(RepairEvent::SessionReady { session_id }).await; + let mut cipher = Cipher::new(conn.comm_privkey.clone(), info.pubkeys.clone()).map_err(|e| CoreError::Crypto(e.to_string()))?; + let mut pending = VecDeque::new(); + let mut io = crate::dkg::SessionIo { client: &client, session_id, cipher: &mut cipher, cancel: &cancel, pending: &mut pending }; + + // What's being repaired: as this helper asked, or as the one who did. + let (identifier, new_key, helpers) = match ¶ms.start { + Some(start) => { + let request = RepairMsg::Request { + group_id: params.group.group_id.clone(), + identifier: start.identifier.clone(), + member: members.get(&start.identifier).map(pubkey_hex).unwrap_or_default(), + new_key: pubkey_hex(&start.new_key), + helpers: start.helpers.iter().map(pubkey_hex).collect(), + }; + let others: Vec = info.pubkeys.iter().filter(|p| **p != conn.comm_pubkey).cloned().collect(); + io.send_to(&others, &encode(&request)?).await?; + (start.identifier.clone(), start.new_key.clone(), start.helpers.clone()) + } + None => { + let from = info.coordinator_pubkey.clone(); + let request = loop { + if let Some(msg) = take(io.pending, |sender, m| *sender == from && matches!(m, RepairMsg::Request { .. })) { + break msg; + } + io.receive_more().await?; + }; + let RepairMsg::Request { group_id, identifier, member, new_key, helpers } = request else { unreachable!() }; + let helpers: Vec = helpers + .iter() + .map(|h| hex::decode(h).map(PublicKey).map_err(|_| CoreError::Ceremony("a malformed helper key".into()))) + .collect::>()?; + let new_key = PublicKey(hex::decode(&new_key).map_err(|_| CoreError::Ceremony("a malformed new key".into()))?); + // Exactly the named helpers and the new key, all helpers members, + // at least the threshold of them, and the member not among them. + let in_session: HashSet<&PublicKey> = info.pubkeys.iter().collect(); + let expected: HashSet<&PublicKey> = helpers.iter().chain(std::iter::once(&new_key)).collect(); + let old_key = members.get(&identifier); + let ok = group_id == params.group.group_id + && in_session == expected + && helpers.iter().all(|h| member_keys.contains(h)) + && helpers.contains(&conn.comm_pubkey) + && helpers.contains(&from) + && !member_keys.contains(&new_key) + && helpers.len() >= usize::from(params.group.threshold) + && old_key.is_some_and(|k| pubkey_hex(k) == member && !helpers.contains(k)); + if !ok { + return Err(CoreError::Ceremony("this repair request doesn't match the group, so it was refused".into())); + } + let _ = events + .send(RepairEvent::AwaitingApproval { group_id, identifier: identifier.clone(), member, new_key: pubkey_hex(&new_key) }) + .await; + (identifier, new_key, helpers) + } + }; + let approved = tokio::select! { + _ = cancel.cancelled() => return Err(CoreError::Cancelled), + r = approval => r.unwrap_or(false), + }; + if !approved { + return Err(CoreError::Ceremony("you declined to help with this repair".into())); + } + + // Step 1: a value for every helper, this one's kept. + let identifier_of = |key: &PublicKey| { + members + .iter() + .find(|(_, k)| *k == key) + .map(|(id, _)| id.clone()) + .ok_or_else(|| CoreError::Ceremony("a helper isn't a member".into())) + }; + let helper_ids: Vec> = + helpers.iter().map(|h| identifier_from_hex::(&identifier_of(h)?)).collect::>()?; + let participant = identifier_from_hex::(&identifier)?; + let mut deltas = deltas::(&helper_ids, &key_package, participant)?; + let own = deltas.remove(key_package.identifier()).ok_or_else(|| CoreError::Ceremony("no value for this helper".into()))?; + for helper in helpers.iter().filter(|h| **h != conn.comm_pubkey) { + let id = identifier_from_hex::(&identifier_of(helper)?)?; + let value = deltas.get(&id).ok_or_else(|| CoreError::Ceremony("no value for a helper".into()))?; + io.send_to(std::slice::from_ref(helper), &encode(&RepairMsg::Delta { value: scalar_hex::(value) })?).await?; + } + + // Step 2: this helper's sum, for the member, with the group's data. + let others: Vec = helpers.iter().filter(|h| **h != conn.comm_pubkey).cloned().collect(); + let mut received: HashMap> = HashMap::new(); + while received.len() < others.len() { + while let Some(msg) = take_from(io.pending, &others, |m| matches!(m, RepairMsg::Delta { .. })) { + let (sender, RepairMsg::Delta { value }) = msg else { unreachable!() }; + received.insert(sender, scalar_from_hex::(&value)?); + } + if received.len() < others.len() { + let missing: Vec = others.iter().filter(|p| !received.contains_key(*p)).map(pubkey_hex).collect(); + let _ = events.try_send(RepairEvent::Waiting { members: missing }); + io.receive_more().await?; + } + } + let mut parts: Vec> = received.into_values().collect(); + parts.push(own); + let sigma = repairable::repair_share_step_2::(&parts); + let mut group = params.group.clone(); + group.members.insert(identifier.clone(), pubkey_hex(&new_key)); + io.send_to(std::slice::from_ref(&new_key), &encode(&RepairMsg::Share { group: Box::new(group), sigma: scalar_hex::(&sigma) })?) + .await?; + let _ = events.send(RepairEvent::ShareSent).await; + + // The member's app checks it. + let done = loop { + if let Some((_, RepairMsg::Done { ok })) = take_from(io.pending, std::slice::from_ref(&new_key), |m| matches!(m, RepairMsg::Done { .. })) { + break ok; + } + io.receive_more().await?; + }; + if !done { + return Err(CoreError::Ceremony("the member's app couldn't rebuild the share, so nothing changed".into())); + } + Ok(HelperOutcome { identifier, new_key }) + }; + let result = tokio::select! { + r = result => r, + _ = tokio::time::sleep(REPAIR_TIMEOUT) => Err(CoreError::Ceremony( + "the repair didn't finish within 30 minutes. Try again when everyone has the app open.".into(), + )), + }; + // The helper who started keeps the session open a while after finishing, + // so the other helpers can still collect the member's confirmation; on + // failure it closes it so they stop waiting. + match (&result, params.start.is_some()) { + (Ok(_), true) => { + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let _ = client.close_session(&api::CloseSessionArgs { session_id }).await; + let _ = client.logout().await; + }); + } + (Err(_), true) => { + let _ = client.close_session(&api::CloseSessionArgs { session_id }).await; + let _ = client.logout().await; + } + (_, false) => { + let _ = client.logout().await; + } + } + result +} + +/// What a repaired member's app ends up with: the group, and its key package. +#[derive(Debug, Clone)] +pub struct Repaired { + pub group: GroupData, + pub key_package: Vec, +} + +/// The repaired member's part: join `session_id`, take every helper's part, +/// rebuild the share, check it, and tell the helpers. +pub async fn receive(conn: Connection, session_id: Uuid, events: mpsc::Sender, cancel: CancellationToken) -> Result { + let mut client = FrostdClient::new(format!("https://{}", conn.server_url), &conn.trust)?; + let _ = events.send(RepairEvent::Connecting).await; + client.login(&conn.comm_privkey, &conn.comm_pubkey).await?; + let result = async { + let info = client.get_session_info(&api::GetSessionInfoArgs { session_id }).await?; + if info.message_count != REPAIR_SESSION || !info.pubkeys.contains(&conn.comm_pubkey) { + return Err(CoreError::Ceremony("this isn't a repair of your share".into())); + } + let _ = events.send(RepairEvent::SessionReady { session_id }).await; + let helpers: Vec = info.pubkeys.iter().filter(|p| **p != conn.comm_pubkey).cloned().collect(); + let mut cipher = Cipher::new(conn.comm_privkey.clone(), info.pubkeys.clone()).map_err(|e| CoreError::Crypto(e.to_string()))?; + let mut pending = VecDeque::new(); + let mut io = crate::dkg::SessionIo { client: &client, session_id, cipher: &mut cipher, cancel: &cancel, pending: &mut pending }; + + let coordinator = info.coordinator_pubkey.clone(); + let identifier = loop { + if let Some((_, RepairMsg::Request { identifier, new_key, helpers: named, .. })) = + take_from(io.pending, std::slice::from_ref(&coordinator), |m| matches!(m, RepairMsg::Request { .. })) + { + let named: HashSet = named.into_iter().collect(); + let actual: HashSet = helpers.iter().map(pubkey_hex).collect(); + if new_key != pubkey_hex(&conn.comm_pubkey) || named != actual { + return Err(CoreError::Ceremony("this repair names someone else, so it was refused".into())); + } + break identifier; + } + io.receive_more().await?; + }; + let mut shares: HashMap = HashMap::new(); + while shares.len() < helpers.len() { + while let Some((sender, msg)) = take_from(io.pending, &helpers, |m| matches!(m, RepairMsg::Share { .. })) { + let RepairMsg::Share { group, sigma } = msg else { unreachable!() }; + shares.insert(sender, (*group, sigma)); + } + if shares.len() < helpers.len() { + let missing: Vec = helpers.iter().filter(|p| !shares.contains_key(*p)).map(pubkey_hex).collect(); + let _ = events.try_send(RepairEvent::Waiting { members: missing }); + io.receive_more().await?; + } + } + let outcome = rebuild_from(&shares, &identifier, &conn.comm_pubkey); + let done = encode(&RepairMsg::Done { ok: outcome.is_ok() })?; + io.send_to(&helpers, &done).await?; + outcome + }; + let result = tokio::select! { + r = result => r, + _ = tokio::time::sleep(REPAIR_TIMEOUT) => Err(CoreError::Ceremony( + "the helpers didn't all send their part within 30 minutes. Ask them to try again.".into(), + )), + }; + let _ = client.logout().await; + result +} + +/// The member's key package from the helpers' parts, which must all describe +/// the same group, naming this device's key as the member's. Where the +/// group's wallet starts scanning is each helper's own record, which can +/// differ a little: the earliest is kept, which never misses anything. +fn rebuild_from(shares: &HashMap, identifier: &str, me: &PublicKey) -> Result { + let without_birthdays = |g: &GroupData| GroupData { birthdays: BTreeMap::new(), ..g.clone() }; + let mut groups = shares.values().map(|(g, _)| g); + let mut group = groups.next().ok_or_else(|| CoreError::Ceremony("no helper sent their part".into()))?.clone(); + if groups.any(|g| without_birthdays(g) != without_birthdays(&group)) { + return Err(CoreError::Ceremony("the helpers described the group differently, so nothing was saved".into())); + } + for (g, _) in shares.values() { + for (key, height) in &g.birthdays { + let earliest = group.birthdays.entry(key.clone()).or_insert(*height); + *earliest = (*earliest).min(*height); + } + } + if group.members.get(identifier) != Some(&pubkey_hex(me)) { + return Err(CoreError::Ceremony("the helpers' group doesn't name your new key, so nothing was saved".into())); + } + let key_package = match group.ciphersuite.as_str() { + id if id == Ed25519Sha512::ID => rebuild_package::(shares, identifier, &group)?, + id if id == PallasBlake2b512::ID => rebuild_package::(shares, identifier, &group)?, + other => return Err(CoreError::Config(format!("can't repair a {other} group"))), + }; + Ok(Repaired { group, key_package }) +} + +fn rebuild_package( + shares: &HashMap, + identifier: &str, + group: &GroupData, +) -> Result, CoreError> { + let pkp_bytes = hex::decode(&group.public_key_package).map_err(|_| CoreError::Ceremony("a malformed public key package".into()))?; + let pkp = PublicKeyPackage::::deserialize(&pkp_bytes).map_err(|e| CoreError::Ceremony(e.to_string()))?; + let key = pkp.verifying_key().serialize().map_err(|e| CoreError::Ceremony(e.to_string()))?; + if hex::encode(key) != group.group_id { + return Err(CoreError::Ceremony("the group's key isn't its id, so nothing was saved".into())); + } + let sigmas: Vec> = shares.values().map(|(_, s)| scalar_from_hex::(s)).collect::>()?; + let package = rebuild::(&sigmas, identifier_from_hex::(identifier)?, &pkp, group.threshold)?; + package.serialize().map_err(|e| CoreError::Ceremony(e.to_string())) +} + +fn encode(m: &RepairMsg) -> Result, CoreError> { + serde_json::to_vec(m).map_err(|e| CoreError::Ceremony(e.to_string())) +} + +/// Take the first message from `from` matching `want` out of `pending`. +fn take_from( + pending: &mut VecDeque, + from: &[PublicKey], + want: impl Fn(&RepairMsg) -> bool, +) -> Option<(PublicKey, RepairMsg)> { + let at = pending.iter().position(|m| { + from.contains(&m.sender) && serde_json::from_slice::(&m.msg).is_ok_and(|parsed| want(&parsed)) + })?; + let msg = pending.remove(at)?; + serde_json::from_slice(&msg.msg).ok().map(|parsed| (msg.sender, parsed)) +} + +/// [`take_from`] for a message from one sender, judged by sender and message. +fn take(pending: &mut VecDeque, want: impl Fn(&PublicKey, &RepairMsg) -> bool) -> Option { + let at = pending + .iter() + .position(|m| serde_json::from_slice::(&m.msg).is_ok_and(|parsed| want(&m.sender, &parsed)))?; + let msg = pending.remove(at)?; + serde_json::from_slice(&msg.msg).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use frost_core::keys::{generate_with_dealer, IdentifierList}; + + /// A 2-of-3 group from a dealer: every member's key package, and the + /// group's public key package. + fn group() -> (BTreeMap, KeyPackage>, PublicKeyPackage) { + let (shares, pkp) = generate_with_dealer::(3, 2, IdentifierList::Default, &mut OsRng).unwrap(); + let packages = shares.into_iter().map(|(id, s)| (id, KeyPackage::try_from(s).unwrap())).collect(); + (packages, pkp) + } + + fn repair() { + let (packages, pkp) = group::(); + let ids: Vec> = packages.keys().copied().collect(); + let (lost, helpers) = (ids[2], &ids[..2]); + // Each helper's step 1, and each helper's step 2 from what it got. + let all: Vec, Scalar>> = + helpers.iter().map(|h| deltas::(helpers, &packages[h], lost).unwrap()).collect(); + let sigmas: Vec> = helpers + .iter() + .map(|h| repairable::repair_share_step_2::(&all.iter().map(|d| d[h]).collect::>())) + .collect(); + let rebuilt = rebuild::(&sigmas, lost, &pkp, 2).unwrap(); + assert_eq!(rebuilt, packages[&lost], "the same share, and the rest of the key package"); + + // A helper sending something else: the share doesn't match, and isn't kept. + let wrong = vec![sigmas[0], sigmas[0]]; + assert!(rebuild::(&wrong, lost, &pkp, 2).is_err()); + } + + #[test] + fn helpers_rebuild_a_lost_share_that_matches_the_groups_record_of_it() { + repair::(); + repair::(); + } + + #[test] + fn a_repair_code_survives_being_pasted_through_a_chat_app() { + let code = RepairCode { + server: "frost.example.com:2744".into(), + cert_pem: None, + session_id: Uuid::new_v4(), + group: "Treasury".into(), + member: "Carol".into(), + }; + let text = code.encode().unwrap(); + let (a, b) = text.split_at(30); + assert_eq!(RepairCode::decode(&format!("Here it is: {a}\n{b} thanks")).unwrap(), code); + assert!(RepairCode::decode("zffrost1notarepaircode").is_err()); + } + + #[test] + fn a_repaired_member_keeps_nothing_unless_every_helper_describes_the_same_group_naming_their_key() { + let me = PublicKey(vec![7; 32]); + let group = GroupData { + group_id: "g".into(), + description: "Treasury".into(), + ciphersuite: PallasBlake2b512::ID.into(), + threshold: 2, + public_key_package: String::new(), + server_url: None, + members: BTreeMap::from([("03".into(), hex::encode(&me.0))]), + viewing_seed: None, + birthdays: BTreeMap::new(), + }; + let other = GroupData { description: "Not the treasury".into(), ..group.clone() }; + let shares = HashMap::from([(PublicKey(vec![1; 32]), (group.clone(), "00".into())), (PublicKey(vec![2; 32]), (other, "00".into()))]); + assert!(rebuild_from(&shares, "03", &me).unwrap_err().to_string().contains("described the group differently")); + let shares = HashMap::from([(PublicKey(vec![1; 32]), (group.clone(), "00".into()))]); + assert!(rebuild_from(&shares, "03", &PublicKey(vec![8; 32])).unwrap_err().to_string().contains("doesn't name your new key")); + // Helpers' wallet start heights may differ; that alone isn't a different group. + let later = GroupData { birthdays: BTreeMap::from([("main/g".into(), 200)]), ..group.clone() }; + let earlier = GroupData { birthdays: BTreeMap::from([("main/g".into(), 100)]), ..group }; + let shares = HashMap::from([(PublicKey(vec![1; 32]), (later, "00".into())), (PublicKey(vec![2; 32]), (earlier, "00".into()))]); + let err = rebuild_from(&shares, "03", &me).unwrap_err().to_string(); + assert!(!err.contains("described the group differently"), "{err}"); + } +} diff --git a/src-tauri/src/commands/contacts.rs b/src-tauri/src/commands/contacts.rs index 330aaed..16609b8 100644 --- a/src-tauri/src/commands/contacts.rs +++ b/src-tauri/src/commands/contacts.rs @@ -20,6 +20,25 @@ pub struct ContactDto { pub verified: bool, } +/// What a contact code holds, without saving it: its name, key and safety +/// code, to compare with its owner. +#[derive(Serialize)] +pub struct ContactCode { + pub name: String, + pub pubkey: String, + pub safety_code: String, +} + +#[tauri::command] +pub async fn read_contact(text: String) -> AppResult { + let contact = frost_app_core::config::contact_from_text(&text)?; + Ok(ContactCode { + name: contact.name.clone(), + pubkey: hex::encode(&contact.pubkey.0), + safety_code: frost_app_core::safety::safety_code(&contact.pubkey.0), + }) +} + #[tauri::command] pub async fn list_contacts(state: State<'_, AppState>) -> AppResult> { let private = state.load_private().await?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e2e2c5d..9dd6a37 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod keystore; pub mod notify; pub mod payees; pub mod polls; +pub mod repair; pub mod rules; pub mod server; pub mod signing; diff --git a/src-tauri/src/commands/polls.rs b/src-tauri/src/commands/polls.rs index f59ba47..a764ae1 100644 --- a/src-tauri/src/commands/polls.rs +++ b/src-tauri/src/commands/polls.rs @@ -383,7 +383,7 @@ pub(crate) async fn write(app: &AppHandle, group_id: &str, body: } /// Send this device's undelivered records to the other members. -async fn deliver(app: &AppHandle, group_id: &str) -> AppResult<()> { +pub(crate) async fn deliver(app: &AppHandle, group_id: &str) -> AppResult<()> { let state = app.state::(); let g = group(&state, group_id).await?; let me = hex::encode(&g.me.0); @@ -465,6 +465,12 @@ pub async fn receive_group_messages(app: AppHandle, group_id: Str }) .await?; updated(&app, &group_id, new_polls); + // Members' new keys after a repair, once enough members vouch for them. + match crate::commands::repair::apply_vouched_keys(&state, &group_id).await { + Ok(true) => tracing::info!("switched to a repaired member's new key"), + Ok(false) => {} + Err(e) => tracing::warn!("couldn't apply a repaired member's new key: {}", e.message), + } // They're catching up: resend what they lack of ours, to them alone. if let Some(after) = resend_after { diff --git a/src-tauri/src/commands/repair.rs b/src-tauri/src/commands/repair.rs new file mode 100644 index 0000000..c0cc9c3 --- /dev/null +++ b/src-tauri/src/commands/repair.rs @@ -0,0 +1,374 @@ +//! Repairing a member's lost share (see `frost_app_core::repair`): starting a +//! repair and helping with one from a group, and receiving one on the new +//! device of the member who lost theirs. +//! +//! Once the member's app has the share back, each helper's app switches the +//! group to the member's new communication key and vouches for it in the +//! group's records (`RecordBody::MemberKey`); the other members' apps switch +//! once as many members vouch for it as a payment needs approvals +//! ([`apply_vouched_keys`]). + +use std::collections::BTreeMap; + +use frost_app_core::polls::RecordBody; +use frost_app_core::repair::{self, Connection, GroupData, HelperParams, RepairCode, RepairEvent, Start}; +use frost_client::api::PublicKey; +use frost_client::cli::config::{Group, Participant}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::commands::polls::write; +use crate::error::{AppError, AppResult}; +use crate::state::{AppState, ApprovalGate, CeremonyHandle}; + +/// This device's connection to a group's coordination server. +async fn connection(state: &AppState, server_url: String) -> AppResult { + let trust = crate::commands::server::trust_for(state, &server_url).await; + state + .with_config(|config| { + let comm = config + .communication_key + .as_ref() + .ok_or_else(|| AppError::new("config", "keystore has no communication key"))?; + Ok(Connection { server_url, trust, comm_privkey: comm.privkey.clone(), comm_pubkey: comm.pubkey.clone() }) + }) + .await +} + +/// The group as this device keeps it, for a repaired member, and its key package. +async fn group_data(state: &AppState, group_id: &str) -> AppResult<(GroupData, Vec)> { + let seed = state.viewing_seed(group_id).await?.map(|s| hex::encode(*s)); + let birthdays: BTreeMap = state + .load_private() + .await? + .network_birthdays + .into_iter() + .filter(|(key, _)| key.ends_with(&format!("/{group_id}"))) + .collect(); + state + .with_config(|config| { + let group = config.group.get(group_id).ok_or_else(|| AppError::new("config", "group not found"))?; + let summary = frost_app_core::config::summarize_group(group_id, group)?; + let data = GroupData { + group_id: group_id.to_string(), + description: group.description.clone(), + ciphersuite: group.ciphersuite.clone(), + threshold: summary.threshold as u16, + public_key_package: hex::encode(&group.public_key_package), + server_url: group.server_url.clone(), + members: group.participant.iter().map(|(id, p)| (id.clone(), hex::encode(&p.pubkey.0))).collect(), + viewing_seed: seed, + birthdays, + }; + Ok((data, group.key_package.clone())) + }) + .await +} + +fn emit_progress(app: &AppHandle, ceremony_id: Uuid, event: &RepairEvent) { + let mut value = serde_json::json!(event); + // The new key's safety code, for the helper to compare with its owner. + if let RepairEvent::AwaitingApproval { new_key, .. } = event { + if let Ok(bytes) = hex::decode(new_key) { + value["new_key_safety_code"] = serde_json::json!(frost_app_core::safety::safety_code(&bytes)); + } + } + let _ = app.emit("repair:progress", serde_json::json!({ "ceremony_id": ceremony_id, "event": value })); +} + +fn forward_events(app: &AppHandle, ceremony_id: Uuid) -> mpsc::Sender { + let (tx, mut rx) = mpsc::channel(32); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + emit_progress(&app, ceremony_id, &event); + } + }); + tx +} + +/// A repair this member started, and the code the member being repaired +/// pastes into their new app. +#[derive(Serialize)] +pub struct StartedRepair { + pub ceremony_id: Uuid, + pub code: String, +} + +/// Start repairing a member's share: `identifier` (hex) is the member, +/// `new_contact` their new contact code, and `helpers` the members helping +/// (hex communication keys, this one included, at least the threshold). +#[tauri::command] +pub async fn start_repair( + app: AppHandle, + group_id: String, + identifier: String, + new_contact: String, + helpers: Vec, +) -> AppResult { + let state = app.state::(); + let new_key = frost_app_core::config::contact_from_text(&new_contact)?.pubkey; + let (data, key_package) = group_data(&state, &group_id).await?; + let me = state + .with_config(|c| Ok(c.communication_key.as_ref().map(|k| hex::encode(&k.pubkey.0)).unwrap_or_default())) + .await?; + let member_keys: Vec<&String> = data.members.values().collect(); + let old_key = data.members.get(&identifier).ok_or_else(|| AppError::new("config", "that isn't a member of this group"))?; + if helpers.contains(old_key) { + return Err(AppError::new("config", "the member being repaired can't help repair their own share")); + } + if !helpers.contains(&me) { + return Err(AppError::new("config", "you have to be one of the helpers")); + } + if !helpers.iter().all(|h| member_keys.contains(&h)) || helpers.len() < usize::from(data.threshold) { + return Err(AppError::new("config", format!("choose at least {} helpers from the group", data.threshold))); + } + if member_keys.contains(&&hex::encode(&new_key.0)) { + return Err(AppError::new("config", "that contact code is already a member's: ask for the member's new one")); + } + let helper_keys: Vec = + helpers.iter().map(|h| hex::decode(h).map(PublicKey)).collect::>().map_err(|_| AppError::new("config", "a malformed helper key"))?; + + let (_, server_url) = crate::commands::signing::group_context(&state, &group_id, None).await?; + let conn = connection(&state, server_url.clone()).await?; + let session_id = repair::create_session(&conn, &helper_keys, &new_key).await?; + let member_name = crate::commands::contacts::list_contacts(state.clone()) + .await? + .into_iter() + .find(|c| c.pubkey == *old_key) + .map(|c| c.alias.unwrap_or(c.name)) + .unwrap_or_else(|| "the member".into()); + let code = RepairCode { + server: server_url.clone(), + cert_pem: state.load_settings().trusted_certs.get(&server_url).cloned(), + session_id, + group: data.description.clone(), + member: member_name, + } + .encode()?; + + let ceremony_id = Uuid::new_v4(); + let (approve, approval) = oneshot::channel(); + let _ = approve.send(true); + let start = Some(Start { identifier, new_key, helpers: helper_keys }); + run_helper(&app, ceremony_id, group_id, HelperParams { conn, session_id, group: data, key_package, start }, approval).await; + Ok(StartedRepair { ceremony_id, code }) +} + +/// Help with a repair another member started: the app shows the request and +/// goes on once this member approves (`respond_to_signing`). +#[tauri::command] +pub async fn join_repair(app: AppHandle, group_id: String, session_id: Uuid) -> AppResult { + let state = app.state::(); + let (data, key_package) = group_data(&state, &group_id).await?; + let (_, server_url) = crate::commands::signing::group_context(&state, &group_id, None).await?; + let conn = connection(&state, server_url).await?; + let ceremony_id = Uuid::new_v4(); + let (approve, approval) = oneshot::channel(); + state.ceremonies.lock().await.insert( + ceremony_id, + CeremonyHandle { cancel: CancellationToken::new(), approval: Some(approve), gate: ApprovalGate::Open }, + ); + run_helper(&app, ceremony_id, group_id, HelperParams { conn, session_id, group: data, key_package, start: None }, approval).await; + Ok(ceremony_id) +} + +async fn run_helper( + app: &AppHandle, + ceremony_id: Uuid, + group_id: String, + params: HelperParams, + approval: oneshot::Receiver, +) { + let state = app.state::(); + let cancel = { + let mut ceremonies = state.ceremonies.lock().await; + let handle = ceremonies + .entry(ceremony_id) + .or_insert_with(|| CeremonyHandle { cancel: CancellationToken::new(), approval: None, gate: ApprovalGate::Open }); + handle.cancel.clone() + }; + let events = forward_events(app, ceremony_id); + let task_app = app.clone(); + tauri::async_runtime::spawn(async move { + let result = repair::help(params, events, approval, cancel).await; + let state = task_app.state::(); + state.ceremonies.lock().await.remove(&ceremony_id); + match result { + Ok(outcome) => { + // Vouch to the members as they are, before switching: an app + // that doesn't know the new key yet refuses a session holding + // it, and needs this to learn it. If it can't go now, the + // others still get it by catching up. + let vouch = RecordBody::MemberKey { identifier: outcome.identifier.clone(), pubkey: hex::encode(&outcome.new_key.0) }; + let vouched = write(&task_app, &group_id, vouch).await; + if let Err(e) = crate::commands::polls::deliver(&task_app, &group_id).await { + tracing::info!("couldn't send the vouch for the new key yet: {}", e.message); + } + let switched = switch_member_key(&state, &group_id, &outcome.identifier, &outcome.new_key).await; + if let Err(e) = vouched.and(switched) { + tracing::warn!("repaired, but couldn't switch to the member's new key: {}", e.message); + } + let _ = task_app.emit("repair:complete", serde_json::json!({ "ceremony_id": ceremony_id, "group_id": group_id })); + } + Err(e) => { + let _ = task_app.emit("repair:failed", serde_json::json!({ "ceremony_id": ceremony_id, "error": e.to_string() })); + } + } + }); +} + +/// Use `new_key` as member `identifier`'s communication key in a group. +async fn switch_member_key(state: &AppState, group_id: &str, identifier: &str, new_key: &PublicKey) -> AppResult<()> { + state + .mutate_config(|config| { + let group = config.group.get_mut(group_id).ok_or_else(|| AppError::new("config", "group not found"))?; + let member = group.participant.get_mut(identifier).ok_or_else(|| AppError::new("config", "member not found"))?; + member.pubkey = new_key.clone(); + Ok(()) + }) + .await +} + +/// Switch to members' new keys that enough members vouch for in the group's +/// records (after a repair this device didn't help with). Returns whether any +/// changed. +pub(crate) async fn apply_vouched_keys(state: &AppState, group_id: &str) -> AppResult { + let g = crate::commands::polls::group(state, group_id).await?; + let logs = state.load_polls().await?; + let Some(log) = logs.get(group_id) else { return Ok(false) }; + let vouched = frost_app_core::polls::vouched_member_keys(log, &g.member_hexes(), g.threshold); + if vouched.is_empty() { + return Ok(false); + } + state + .mutate_config(|config| { + let Some(group) = config.group.get_mut(group_id) else { return Ok(false) }; + let mut changed = false; + for (identifier, key) in vouched { + let Ok(key) = hex::decode(&key) else { continue }; + if let Some(member) = group.participant.get_mut(&identifier) { + if member.pubkey.0 != key { + member.pubkey = PublicKey(key); + changed = true; + } + } + } + Ok(changed) + }) + .await +} + +/// What a repair code says, to show before joining. +#[derive(Serialize)] +pub struct RepairCodeInfo { + pub group: String, + pub member: String, + pub server: String, +} + +#[tauri::command] +pub async fn read_repair_code(code: String) -> AppResult { + let code = RepairCode::decode(&code)?; + Ok(RepairCodeInfo { group: code.group, member: code.member, server: code.server }) +} + +/// On the new device of the member being repaired: join the repair a repair +/// code points to, and once every helper's part checks out, keep the group. +#[tauri::command] +pub async fn receive_repair(app: AppHandle, code: String) -> AppResult { + let state = app.state::(); + let code = RepairCode::decode(&code)?; + // The server it runs on, trusted as the helper's app trusts it. + let mut settings = state.load_settings(); + if let Some(pem) = &code.cert_pem { + frost_app_core::tls::cert_fingerprint(pem)?; + settings.trusted_certs.insert(code.server.clone(), pem.clone()); + } + if settings.server_url.is_none() { + settings.server_url = Some(code.server.clone()); + } + state.save_settings(&settings)?; + let conn = connection(&state, code.server.clone()).await?; + + let ceremony_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + state + .ceremonies + .lock() + .await + .insert(ceremony_id, CeremonyHandle { cancel: cancel.clone(), approval: None, gate: ApprovalGate::Open }); + let events = forward_events(&app, ceremony_id); + let task_app = app.clone(); + let session_id = code.session_id; + tauri::async_runtime::spawn(async move { + let result = repair::receive(conn, session_id, events, cancel).await; + let state = task_app.state::(); + state.ceremonies.lock().await.remove(&ceremony_id); + let saved = match result { + Ok(repaired) => keep_repaired_group(&state, repaired).await, + Err(e) => Err(AppError::from(e)), + }; + match saved { + Ok(group_id) => { + let _ = task_app.emit("repair:complete", serde_json::json!({ "ceremony_id": ceremony_id, "group_id": group_id })); + let _ = task_app.emit("polls:updated", serde_json::json!({ "group_id": group_id, "new_polls": 0 })); + } + Err(e) => { + let _ = task_app.emit("repair:failed", serde_json::json!({ "ceremony_id": ceremony_id, "error": e.message })); + } + } + }); + Ok(ceremony_id) +} + +/// Save a group a repair gave this device: the group with its key package, +/// its viewing seed, and where its wallet starts scanning. +async fn keep_repaired_group(state: &AppState, repaired: repair::Repaired) -> AppResult { + let data = repaired.group; + let group_id = data.group_id.clone(); + let participant = data + .members + .iter() + .map(|(id, key)| { + let identifier = hex::decode(id).map_err(|_| AppError::new("repair", "a malformed member identifier"))?; + let pubkey = PublicKey(hex::decode(key).map_err(|_| AppError::new("repair", "a malformed member key"))?); + Ok((id.clone(), Participant { identifier, pubkey })) + }) + .collect::>>()?; + let group = Group { + description: data.description.clone(), + ciphersuite: data.ciphersuite.clone(), + public_key_package: hex::decode(&data.public_key_package).map_err(|_| AppError::new("repair", "a malformed public key package"))?, + key_package: repaired.key_package, + server_url: data.server_url.clone(), + participant, + }; + let seed = data.viewing_seed.clone(); + let id = group_id.clone(); + state + .mutate_keystore(move |config, extras| { + if config.group.contains_key(&id) { + return Err(AppError::new("repair", "this device already has that group")); + } + if let Some(seed) = seed { + extras.viewing_seeds.insert(id.clone(), seed); + } + config.group.insert(id, group); + Ok(()) + }) + .await?; + let birthdays = data.birthdays.clone(); + state + .update_private(|p| { + for (key, height) in birthdays { + p.network_birthdays.entry(key).or_insert(height); + } + }) + .await?; + state.sync_service.wake(false); + Ok(group_id) +} diff --git a/src-tauri/src/commands/signing.rs b/src-tauri/src/commands/signing.rs index 97b9dc2..93d9e82 100644 --- a/src-tauri/src/commands/signing.rs +++ b/src-tauri/src/commands/signing.rs @@ -708,11 +708,14 @@ pub async fn list_pending_sessions( // session is a member of the group: a threshold subset of group A is not // a subset of group B unless B contains all of them. let session_pubkeys: Vec<&Vec> = info.pubkeys.iter().map(|p| &p.0).collect(); + // A repair session also holds the repaired member's new key, which + // isn't a member yet: exactly one key outside the group. + let repair = info.message_count == frost_app_core::repair::REPAIR_SESSION; let matching_groups = groups .iter() .filter(|(_, members)| { - members.contains(&info.coordinator_pubkey.0) - && session_pubkeys.iter().all(|pk| members.contains(pk)) + let outsiders = session_pubkeys.iter().filter(|pk| !members.contains(pk)).count(); + members.contains(&info.coordinator_pubkey.0) && outsiders == usize::from(repair) }) .map(|(id, _)| id.clone()) .collect(); @@ -721,6 +724,7 @@ pub async fn list_pending_sessions( kind: match info.message_count { frost_app_core::viewing::NEW_VIEWING_KEY_SESSION => "viewing_key", frost_app_core::polls::GROUP_MESSAGE_SESSION => "group_message", + frost_app_core::repair::REPAIR_SESSION => "repair", _ => "signing", }, coordinator, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04a2130..8d10455 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,6 +87,11 @@ pub fn run() { commands::payees::trust_payee, commands::rules::group_rules, commands::export::save_csv, + commands::repair::start_repair, + commands::contacts::read_contact, + commands::repair::join_repair, + commands::repair::read_repair_code, + commands::repair::receive_repair, commands::rules::auto_approval_status, commands::rules::set_auto_approve, commands::tor::set_use_tor, diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs index 32a9456..dbbf718 100644 --- a/src-tauri/tests/common/mod.rs +++ b/src-tauri/tests/common/mod.rs @@ -245,3 +245,70 @@ pub async fn until(p: &Pair, group_id: &str, what: &str, done: impl AsyncFn() -> tokio::time::sleep(Duration::from_millis(300)).await; } } + +/// A member of a group made by [`group_of`]: their app and communication key. +pub struct Member { + pub user: UserApp, + pub pubkey: String, +} + +/// A user with a keystore, the test server configured and trusted: a member's +/// app, or a new device. +pub async fn user_on(server: &TestServer, name: &str) -> Member { + let user = make_user(); + let state = user.app.state::(); + commands::keystore::create_keystore(state.clone(), format!("{name}-passphrase")).await.unwrap(); + let server_url = format!("127.0.0.1:{}", server.port); + commands::server::set_server_url(state.clone(), server_url.clone()).await.unwrap(); + commands::server::trust_server_cert(state.clone(), server_url, server.cert_pem.clone()).await.unwrap(); + let pubkey = commands::contacts::export_my_contact(state, name.into()).await.unwrap().pubkey; + Member { user, pubkey } +} + +/// A `threshold`-of-`names.len()` group of new users who are each other's +/// contacts, made by key generation with a private viewing key. Returns them, +/// in `names` order, and the group's id. +pub async fn group_of(server: &TestServer, names: &[&str], threshold: u16) -> (Vec, String) { + let mut members = Vec::new(); + for name in names { + members.push(user_on(server, name).await); + } + for (i, a) in members.iter().enumerate() { + let state = a.user.app.state::(); + for (j, b) in members.iter().enumerate() { + if i != j { + let text = commands::contacts::export_my_contact(b.user.app.state::(), names[j].into()).await.unwrap().text; + commands::contacts::add_contact(state.clone(), text, None).await.unwrap(); + } + } + } + let server_url = format!("127.0.0.1:{}", server.port); + let args = |participants: Vec| commands::dkg::StartDkgArgs { + suite: frost_app_core::ciphersuite::Suite::RedPallas, + description: "test group".into(), + threshold, + participants, + server_url: Some(server_url.clone()), + session_id: None, + agree_viewing_key: true, + }; + let others: Vec = members[1..].iter().map(|m| m.pubkey.clone()).collect(); + commands::dkg::start_dkg(members[0].user.app.handle().clone(), args(others)).await.unwrap(); + tokio::time::sleep(Duration::from_millis(1500)).await; + for m in &members[1..] { + commands::dkg::start_dkg(m.user.app.handle().clone(), args(vec![])).await.unwrap(); + } + let mut group_id = String::new(); + for m in &members { + let state = m.user.app.state::(); + for _ in 0..90 { + if let Some(g) = commands::groups::list_groups(state.clone()).await.unwrap().first() { + group_id = g.id.clone(); + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + assert!(!group_id.is_empty(), "key generation didn't complete"); + } + (members, group_id) +} diff --git a/src-tauri/tests/repair.rs b/src-tauri/tests/repair.rs new file mode 100644 index 0000000..6cabeb5 --- /dev/null +++ b/src-tauri/tests/repair.rs @@ -0,0 +1,142 @@ +//! Repairing a member's lost share over a real frostd, in a 2-of-4 group: +//! Carol loses her device, Alice and Bob help, and Dave stands in for Carol's +//! new device. Dave's app rebuilds exactly Carol's key package and gets the +//! group's viewing seed; Alice's and Bob's apps switch to his key; Erin's, +//! who didn't help, switches once both helpers vouch for it. + +mod common; + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use common::{group_of, user_on}; +use frost_app_lib::commands; +use frost_app_lib::state::AppState; +use tauri::{Listener, Manager}; + +/// Read every group-message session waiting for a member, as the app does, +/// shrugging off the ones it refuses (as it does, logging them). +async fn pump(m: &common::Member, group_id: &str) { + for s in commands::signing::list_pending_sessions(state(m), None).await.unwrap_or_default() { + if s.kind == "group_message" { + let _ = commands::polls::receive_group_messages(m.user.app.handle().clone(), group_id.to_string(), s.session_id).await; + } + } +} + +fn state(m: &common::Member) -> tauri::State<'_, AppState> { + m.user.app.state::() +} + +/// Carol's entry in a member's copy of the group: identifier (hex) and key. +async fn member_key(state: &tauri::State<'_, AppState>, group_id: &str, identifier: &str) -> String { + state + .with_config(|c| Ok(hex::encode(&c.group[group_id].participant[identifier].pubkey.0))) + .await + .unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +async fn helpers_repair_a_lost_share_for_the_members_new_device() { + let Some(server) = common::start_server().await else { + eprintln!("skipping: sidecar frostd binary not built (run scripts/build-sidecar.sh)"); + return; + }; + tauri::async_runtime::set(tokio::runtime::Handle::current()); + let (members, group_id) = group_of(&server, &["alice", "bob", "carol", "erin"], 2).await; + let [alice, bob, carol, erin] = &members[..] else { unreachable!() }; + let carol_id = state(alice) + .with_config(|c| { + Ok(c.group[&group_id].participant.iter().find(|(_, p)| hex::encode(&p.pubkey.0) == carol.pubkey).unwrap().0.clone()) + }) + .await + .unwrap(); + let carols_package = state(carol).with_config(|c| Ok(c.group[&group_id].key_package.clone())).await.unwrap(); + let seed = state(alice).viewing_seed(&group_id).await.unwrap().map(|s| *s); + assert!(seed.is_some()); + + // Carol's new device: a fresh keystore, a new key. She gives the group her + // new contact code. + let dave = user_on(&server, "carol").await; + let new_contact = commands::contacts::export_my_contact(state(&dave), "carol".into()).await.unwrap().text; + + // Alice starts the repair with Bob, and hands Carol the repair code. + let started = commands::repair::start_repair( + alice.user.app.handle().clone(), + group_id.clone(), + carol_id.clone(), + new_contact, + vec![alice.pubkey.clone(), bob.pubkey.clone()], + ) + .await + .unwrap(); + let info = commands::repair::read_repair_code(started.code.clone()).await.unwrap(); + assert_eq!((info.group.as_str(), info.member.as_str()), ("test group", "carol")); + let done: Arc>> = Arc::default(); + for (who, m) in [("alice", alice), ("dave", &dave), ("bob", bob)] { + let done = done.clone(); + m.user.app.handle().listen("repair:complete", move |_| done.lock().unwrap().push(who.into())); + let failed = who.to_string(); + m.user.app.handle().listen("repair:failed", move |e| panic!("{failed}'s repair failed: {}", e.payload())); + } + commands::repair::receive_repair(dave.user.app.handle().clone(), started.code).await.unwrap(); + + // Bob sees the request, checks the new key with Carol, and approves. + let asked: Arc>> = Arc::default(); + let seen = asked.clone(); + bob.user.app.handle().listen("repair:progress", move |e| { + let v: serde_json::Value = serde_json::from_str(e.payload()).unwrap(); + if v["event"]["phase"] == "awaiting_approval" { + *seen.lock().unwrap() = Some(v); + } + }); + let started_at = Instant::now(); + let session = loop { + let sessions = commands::signing::list_pending_sessions(state(bob), None).await.unwrap(); + if let Some(s) = sessions.into_iter().find(|s| s.kind == "repair") { + assert_eq!(s.matching_groups, vec![group_id.clone()]); + break s.session_id; + } + assert!(started_at.elapsed() < Duration::from_secs(30), "Bob never saw the repair"); + tokio::time::sleep(Duration::from_millis(300)).await; + }; + let ceremony = commands::repair::join_repair(bob.user.app.handle().clone(), group_id.clone(), session).await.unwrap(); + let request = loop { + if let Some(v) = asked.lock().unwrap().clone() { + break v; + } + assert!(started_at.elapsed() < Duration::from_secs(60), "Bob was never asked"); + tokio::time::sleep(Duration::from_millis(200)).await; + }; + assert_eq!(request["event"]["new_key"], dave.pubkey.as_str()); + assert_eq!(request["event"]["member"], carol.pubkey.as_str()); + commands::signing::respond_to_signing(state(bob), ceremony, true).await.unwrap(); + + while done.lock().unwrap().len() < 3 { + assert!(started_at.elapsed() < Duration::from_secs(120), "the repair didn't finish: {:?}", done.lock().unwrap()); + tokio::time::sleep(Duration::from_millis(300)).await; + } + + // Dave's app holds exactly Carol's share, and the group's viewing key. + let daves_package = state(&dave).with_config(|c| Ok(c.group[&group_id].key_package.clone())).await.unwrap(); + assert_eq!(daves_package, carols_package); + assert_eq!(state(&dave).viewing_seed(&group_id).await.unwrap().map(|s| *s), seed); + assert_eq!(member_key(&state(&dave), &group_id, &carol_id).await, dave.pubkey); + + // The helpers use his key now; Erin's app does once both have vouched. + assert_eq!(member_key(&state(alice), &group_id, &carol_id).await, dave.pubkey); + let switched = Instant::now(); + while member_key(&state(bob), &group_id, &carol_id).await != dave.pubkey + || member_key(&state(erin), &group_id, &carol_id).await != dave.pubkey + { + for m in [alice, bob, erin] { + pump(m, &group_id).await; + } + if switched.elapsed() > Duration::from_secs(60) { + let bob_key = member_key(&state(bob), &group_id, &carol_id).await; + let erin_key = member_key(&state(erin), &group_id, &carol_id).await; + panic!("not switched: bob {} erin {} (dave {}, carol {}), done {:?}", &bob_key[..8], &erin_key[..8], &dave.pubkey[..8], &carol.pubkey[..8], done.lock().unwrap()); + } + tokio::time::sleep(Duration::from_millis(300)).await; + } +} diff --git a/src/App.tsx b/src/App.tsx index d2a0e06..ce38e85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { listPendingSessions, notifyIfAway, receiveGroupMessages, + repairRequests, signingRequests, } from "./ipc/commands"; import CeremonyListener from "./CeremonyListener"; @@ -71,7 +72,7 @@ function Layout() { refetchInterval: 10_000, retry: false, }); - const pendingCount = signingRequests(pendingSessions.data).length; + const pendingCount = signingRequests(pendingSessions.data).length + repairRequests(pendingSessions.data).length; const pollsAwaiting = usePollsAwaitingMe(); // A request that arrives while the app is in the background gets a system diff --git a/src/CeremonyListener.tsx b/src/CeremonyListener.tsx index 65cebc1..9d8637b 100644 --- a/src/CeremonyListener.tsx +++ b/src/CeremonyListener.tsx @@ -73,6 +73,17 @@ export default function CeremonyListener() { }); useTauriEvent("dkg:failed", (p) => onFailed("dkg", p)); + // Repairing a member's share (commands/repair.rs): on a helper's app the + // group switches to the member's new key, and on the member's new device a + // group arrives. + useTauriEvent("repair:progress", (p) => onProgress("repair", p)); + useTauriEvent("repair:complete", (p) => { + onComplete("repair", p); + queryClient.invalidateQueries({ queryKey: ["groups"] }); + queryClient.invalidateQueries({ queryKey: ["pending-sessions"] }); + }); + useTauriEvent("repair:failed", (p) => onFailed("repair", p)); + useTauriEvent("signing:progress", (p) => { onProgress("signing", p); const auto = (p.event as { auto_limit?: { approved?: boolean } } | undefined)?.auto_limit; diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index cb9eac8..bf0e20d 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -533,7 +533,7 @@ export interface PendingSession { session_id: string; /** "viewing_key" sessions agree on a group's private viewing key; the app * joins them by itself, so they aren't requests to approve. */ - kind: "signing" | "viewing_key" | "group_message"; + kind: "signing" | "viewing_key" | "group_message" | "repair"; coordinator: string | null; coordinator_pubkey: string; matching_groups: string[]; @@ -563,6 +563,31 @@ export const listPendingSessions = (serverUrl: string | null) => * joins by itself). */ export const signingRequests = (sessions: PendingSession[] | undefined) => (sessions ?? []).filter((s) => s.kind === "signing"); +/** Requests to help repair a member's lost share (commands/repair.rs). */ +export const repairRequests = (sessions: PendingSession[] | undefined) => + (sessions ?? []).filter((s) => s.kind === "repair" && s.matching_groups.length > 0); + +/** What a contact code holds, without saving it. */ +export interface ContactCode { + name: string; + pubkey: string; + safety_code: string; +} +export const readContact = (text: string) => invoke("read_contact", { text }); + +/** Start repairing member `identifier`'s lost share with these helpers (this + * member included). Returns the ceremony and the code for the member's new + * app. */ +export const startRepair = (groupId: string, identifier: string, newContact: string, helpers: string[]) => + invoke<{ ceremony_id: string; code: string }>("start_repair", { groupId, identifier, newContact, helpers }); +/** Help with a repair another member started (approved with respondToSigning). */ +export const joinRepair = (groupId: string, sessionId: string) => + invoke("join_repair", { groupId, sessionId }); +/** What a repair code is for. */ +export const readRepairCode = (code: string) => + invoke<{ group: string; member: string; server: string }>("read_repair_code", { code }); +/** On the repaired member's new device: join the repair and keep the group. */ +export const receiveRepair = (code: string) => invoke("receive_repair", { code }); /** A group poll (see commands/polls.rs). */ export interface PollView { diff --git a/src/screens/Groups.tsx b/src/screens/Groups.tsx index 93f5b6e..5bf6afd 100644 --- a/src/screens/Groups.tsx +++ b/src/screens/Groups.tsx @@ -2,8 +2,14 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "react-router-dom"; import { + exportMyContact, getIdentity, groupOrchardKeys, + readContact, + readRepairCode, + receiveRepair, + startRepair, + AppError, viewingKeyStatus, listContacts, listGroups, @@ -18,6 +24,7 @@ import { QRCodeSVG } from "qrcode.react"; import { resolveParticipant } from "../lib/participants"; import Contacts from "./Contacts"; import { KeyRow } from "../wallet/common"; +import { useCeremonies } from "../stores/ceremonies"; function isOrchard(group: GroupSummary): boolean { return group.ciphersuite.includes("Pallas"); @@ -113,15 +120,23 @@ function ReadOnlyAccess({ group }: { group: GroupSummary }) { ); } -/** Guided, accurate explanation of FROST's repairable-share recovery, shown in - * the group flow so a member knows what to do if they lose their share. */ -function ShareRepairGuide({ group }: { group: GroupSummary }) { +/** Recovering from a lost share: what protects each member, and repairing a + * member's share with the others' help (FROST's repairable threshold scheme, + * commands/repair.rs). */ +function ShareRepairGuide({ + group, + identity, + contacts, +}: { + group: GroupSummary; + identity: Identity | undefined; + contacts: ContactDto[] | undefined; +}) { const [open, setOpen] = useState(false); const t = group.threshold; const n = group.num_participants; - // Repairing one member's share needs `t` *other* members to help. There are - // only n-1 others, so it is possible only when t <= n-1, i.e. t < n. An - // m-of-m group (t === n) therefore cannot repair a lost share. + // Repairing one member's share needs `t` *other* members, and there are + // only n-1, so a t-of-t group can't repair one. const repairable = t < n; return ( @@ -129,138 +144,163 @@ function ShareRepairGuide({ group }: { group: GroupSummary }) { - {open && (
- {repairable ? ( -
- - Because this is a {t}-of-{n} group, a lost or - corrupted share is not fatal. Any {t} of the - other {n - 1} members can help you regenerate your share - — without ever revealing it and without anyone reconstructing the - full group key. This is FROST's repairable threshold{" "} - scheme. - -
- ) : ( -
- - This is an {t}-of-{n} group — every member is - required to sign. Repairing one member's share itself needs{" "} - {t} other members to help, but only {n - 1}{" "} - exist, so a lost share in this group cannot be repaired. - Your only protection against losing a share is your recovery code - and a current backup file (Settings → Key backup). If a member permanently - loses their share, the group must run a new DKG to form a fresh - group. - -
- )} - -

How your recovery options fit together

  • - Forgot your passphrase but still have this device: - use your 12-word recovery code to set a new one. + Forgot your passphrase but still have this device: use your 12-word recovery code to set + a new one.
  • - Device failure, but you kept a backup file: on the - new device choose Restore from a backup, then unlock with your - passphrase or recovery code. + New device, with a backup file: choose Restore from a backup on it.
  • - Lost the share entirely (no device, no backup):{" "} - {repairable ? ( - <> - the group can rebuild it with FROST's repair protocol (below), - but that isn't built into Cyze yet. Until it is, while at least{" "} - {t} members still hold their shares, move the funds to a new group - made with a fresh DKG. - - ) : ( - <> - this {t}-of-{n} group cannot repair a share — the group would - need to form a new one with a fresh DKG. - - )} + Lost your share entirely (no device, no backup):{" "} + {repairable + ? `${t} of the other members can rebuild it on your new device, without moving the funds. Set up Cyze there, then follow Recover a group under Groups.` + : `this ${t}-of-${n} group can't repair a share: it would need ${t} other members, and there are ${n - 1}. Keep your recovery code and a backup current; if a share is lost, form a new group and move the funds.`}
+ {repairable && } +
+ )} + + ); +} - {repairable && ( - <> -

Before you start

-
- - You'll need: a working install of this app holding this group's - public data (already stored here), at least {t}{" "} - other participants online and willing to help, and agreement on - the identifier of the member being repaired. - -
- -

How repair works (not in the app yet)

-
    -
  1. -
    Choose your helpers
    -
    - Pick any {t} of the other {n - 1} participants who still have - their shares. They are the “helpers.” Fewer than {t} cannot - repair a share — that is the security threshold working as - intended. -
    -
  2. -
  3. -
    Round 1 — helpers compute repair deltas
    -
    - Each helper uses their own share to compute a random blinding - value (a “delta”) for every other helper and sends it to them - over an encrypted channel. No delta reveals anything about a - share. -
    -
  4. -
  5. -
    Round 2 — helpers combine into a “sigma”
    -
    - Each helper sums the deltas they received into a single value - (their “sigma”) and sends it privately to you, the member - being repaired. -
    -
  6. -
  7. -
    Round 3 — you reconstruct your share
    -
    - Your device combines the {t} sigmas with your identifier and - the group's public commitments to rebuild your secret share - and key package — entirely locally. -
    -
  8. -
  9. -
    Verify
    -
    - Run a test signing session with the - group to confirm your repaired share produces valid signatures. -
    -
  10. -
+/** Repairing another member's lost share: who, their new contact code (its + * safety code compared with them), and the helpers. Shows the repair code to + * hand them, then the repair's progress. */ +function RepairMember({ + group, + identity, + contacts, +}: { + group: GroupSummary; + identity: Identity | undefined; + contacts: ContactDto[] | undefined; +}) { + const me = identity?.pubkey ?? null; + const name = (pk: string) => resolveParticipant(pk, identity, contacts).label; + const members = Object.entries(group.participants); + const [lost, setLost] = useState(""); + const [contactText, setContactText] = useState(""); + const [compared, setCompared] = useState(false); + const [picked, setPicked] = useState>(new Set()); + const [started, setStarted] = useState<{ ceremony_id: string; code: string } | null>(null); + const contact = useQuery({ + queryKey: ["contact-code", contactText.trim()], + queryFn: () => readContact(contactText.trim()), + enabled: contactText.trim().length > 20, + retry: false, + }); + const lostKey = group.participants[lost]; + const helpers = new Set([...picked, ...(me ? [me] : [])].filter((pk) => pk !== lostKey)); + const start = useMutation({ + mutationFn: () => startRepair(group.id, lost, contactText.trim(), [...helpers]), + onSuccess: setStarted, + }); + const ceremony = useCeremonies((s) => (started ? s.ceremonies[started.ceremony_id] : undefined)); -
- - Privacy guarantee: at no point does any helper - learn your share, and the full group secret is never - reconstructed. Helpers only ever exchange random blinding values. - -
- + if (started) { + const waiting = (ceremony?.detail?.members as string[] | undefined) ?? []; + return ( +
+

+ Give {name(lostKey)} this repair code, privately. On their new device they paste it under{" "} + Groups → Recover a group. +

+ +

+ {ceremony?.failed ? ( + {ceremony.error} + ) : ceremony?.done ? ( + + ✓ Repaired: {name(lostKey)}'s new device has their share, and the group uses their new key. + + ) : ceremony?.phase === "share_sent" ? ( + Sent your part. Waiting for {name(lostKey)}'s app to rebuild and check the share… + ) : waiting.length ? ( + Waiting for {waiting.map((pk) => (pk === contact.data?.pubkey ? name(lostKey) : name(pk))).join(", ")}… + ) : ( + Waiting for the other helpers to approve, and for {name(lostKey)} to join… )} +

+
+ ); + } -

- The protocol above is FROST's repairable threshold scheme - (frost-core). A guided in-app repair - ceremony is planned. Until then, keep your recovery code and a current - backup file, so a lost device never needs a repair. -

- + return ( +
+

Repair a member's share

+

+ Repair rebuilds the same share on the member's new device. If their device might have been stolen rather than + lost, don't repair: whoever has it still holds the share. Form a new group and move the funds instead. +

+ + + {lost && ( + <> + +