Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/core/src/dkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand All @@ -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! {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions src-tauri/core/src/polls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<String, String> {
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> {
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading