From ec45a16381e262c3e7b1a5792f020320a638923c Mon Sep 17 00:00:00 2001 From: blocknodes Date: Mon, 26 Oct 2026 05:27:32 +0000 Subject: [PATCH] feat(groups): repair a member's lost share A member who lost their device and backup gets their share back from as many members as the threshold, with FROST's repairable threshold scheme (frost_core::keys::repairable), without moving the funds. The helpers never learn the share, and a group where every member must sign can't repair one. core/src/repair.rs runs it over a session marked REPAIR_SESSION, holding the helpers and the member's new communication key: the helper who starts sends a Request; each helper sends every other a Delta (step 1), sums what it got and sends the member a Share (step 2) with the group's data (its public key package, members, viewing seed and where its wallet starts); the member's app rebuilds the key package (step 3), checks it against the group's public verifying share for them, and answers Done. A joining helper refuses a session that isn't exactly the named helpers plus one outside key, or whose member is a helper or isn't a member. The member's app keeps nothing unless every helper describes the same group naming its key (start heights aside: the earliest is kept). The member comes back with a new key, so helpers compare its safety code with them before approving, the trust decision. Each helper's app vouches for the new key in the group's records (MemberKey), delivered to the members as they were, then switches; the other members' apps switch once as many members vouch as a payment needs. The starting helper keeps the session open a minute after success, so the others can still read the member's confirmation. Repair rebuilds the same share, so the interface says to move the funds instead if a device may have been stolen. Interface: Recover a group on the new device (its contact code, then the repair code), Repair a member's share on the group's page, requests with the new key's safety code in Approvals. Tested: unit tests rebuild a dealer-made share exactly (RedPallas and Ed25519) and refuse a wrong part; a live test over frostd repairs Carol's share in a 2-of-4 group onto a new device, byte for byte with its viewing seed, and Erin's app, which didn't help, switches to the new key. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + PRIVACY.md | 3 + TODO.md | 7 +- docs/USER_GUIDE.md | 33 ++ src-tauri/core/src/dkg.rs | 4 +- src-tauri/core/src/lib.rs | 1 + src-tauri/core/src/polls.rs | 52 +++ src-tauri/core/src/repair.rs | 672 +++++++++++++++++++++++++++++ src-tauri/src/commands/contacts.rs | 19 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/polls.rs | 8 +- src-tauri/src/commands/repair.rs | 374 ++++++++++++++++ src-tauri/src/commands/signing.rs | 8 +- src-tauri/src/lib.rs | 5 + src-tauri/tests/common/mod.rs | 67 +++ src-tauri/tests/repair.rs | 142 ++++++ src/App.tsx | 3 +- src/CeremonyListener.tsx | 11 + src/ipc/commands.ts | 27 +- src/screens/Groups.tsx | 375 ++++++++++------ src/screens/Inbox.tsx | 91 +++- src/stores/ceremonies.ts | 2 +- src/test/approve.test.tsx | 19 + src/test/recover.test.tsx | 21 + src/test/repair.test.tsx | 30 ++ 25 files changed, 1845 insertions(+), 136 deletions(-) create mode 100644 src-tauri/core/src/repair.rs create mode 100644 src-tauri/src/commands/repair.rs create mode 100644 src-tauri/tests/repair.rs create mode 100644 src/test/recover.test.tsx create mode 100644 src/test/repair.test.tsx 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 && ( + <> + +