Sharing
A store is shared by several people, each with several devices. Someone must be allowed to add Dan, and nobody may remove the last admin. There is no server to ask, so every device reaches the same answer on its own, from the store's sequence. This page covers members, roles and devices, how a person's new device gets in, how a new person gets in, and removing people.
Members, roles and devices
A member is a person. Each member has a random, permanent MemberId and two key pairs: one to sign with and one that others seal keys to. A device is one installation of the app, belonging to one member, with its own id. Every slot is signed with its author's member key, and every device checks that signature against the membership as it stood just before that slot.
handle.members() (live: subscribe_members()) lists the active members as this device has applied them:
pub struct MemberInfo {
pub id: MemberId,
pub keys: MemberPublicKeys,
pub role: MemberRole, // Admin or Member
pub devices: Vec<DeviceInfo>, // id, name, and Active, Removed or Replaced
pub is_self: bool, // the member using this device
}coven knows members by id and devices by name; what to call each person is up to the app. Members and profiles shows the pattern.
| Action | Who may do it |
|---|---|
| Create the store | the first device, which becomes the first admin |
| Add or remove a member, change a role | an admin |
| Add a device | the member it belongs to, from that new device |
| Remove a device | its member, or an admin |
| Replace a member's keys | that member, or an admin |
| Create a circle | any member, who becomes its first member |
| Rename a circle, add or remove its members, delete it | its members only; an admin outside it can't |
A store may have several admins and always keeps at least one: removing or demoting the last admin is refused. The member whose provider account holds the store, the Dropbox folder's owner say, can't be removed either, because in coven nobody else can take provider access back; that fails with SyncError::StoreOwner. On OneDrive and CloudKit that is final: neither can hand a store to another account. Dropbox can transfer a folder's ownership, but coven doesn't use that transfer (bae-fm/coven#237).
How admin changes are made
Every change to membership or settings is an admin entry in a slot, and every admin change follows one path, an operation:
- Journal. Renaming the store, changing a role, removing a member or a device, and renaming, deleting or changing the members of a circle are steps of the app's write. The write checks the change against what this device has applied and journals it, offline too, so the app's own rows about it commit with it. A change that state already refuses fails the write with
SyncError::Rejected(OperationRejection). Making a circle is a step too, and so are making an invite and approving, declining or cancelling one. - Catch up and check. A pass reads and applies the sequence to its end and checks the change again.
- Prepare. Record each piece of preparation in the operation's journal before doing it, such as a new key and its copies, or a provider share.
- Claim the next slot with the entry.
- If someone else claimed it first, apply their slot and check again. If the change is still allowed, claim the next slot. If not, undo what the journal recorded and end rejected.
let removal = handle
.write(move |sql| {
let removal = sql.remove_member(&dan)?;
sql.execute("UPDATE people SET leaving=1 WHERE member=?1", [dan])?;
Ok::<_, CovenError>(removal)
})
.await?;Each step returns the operation's id, or None when the store already has what it asks for. The view coven_operations lists the operation from that write's commit, with its outcome once it ends: Landed at its slot, Satisfied, or Rejected. It stays listed, across restarts, until a write acknowledges it with acknowledge_operation(id), which is where the app updates its row. handle.operation_outcome(id) waits for the outcome, for an app that waits in place. handle.remove_member(&dan) and the other handle calls are the same step in a write of their own.
Ana and Ben, both admins, each try to remove the other. Ana's removal claims slot 41 first. Ben's device reads it, finds Ben no longer an admin, undoes its work, and his removal ends Rejected(NotAllowed). No removal of Ana ever enters history.
A claimed entry is final. An operation survives a crash or a dropped call: it settles its claim, then finishes or undoes. Waiting operations appear in pending() with their operation id, and retry_pending_operation(id) makes a retry due now.
A person's new device: the restore code
A restore code holds the member's id and current private keys, the store's id and the storage location. It carries no store name or OAuth token. Setup returns it, and handle.restore_code() shows it again, with no S3 key in it. Its text form starts with CVR1-.
A new device gets the code by scanning a QR code on one of the person's devices, from iCloud Keychain on Apple platforms, from Google's Block Store on a new Android phone set up from the old one, or by typing it. Coven saves the code there after setup, joining and key replacement. Storage access is separate: on S3 the person enters their access key, which they keep apart, in a password manager say. The QR code their own device shows comes from handle.restore_code_for_transfer(), which holds the key that device uses, and the saved entry keeps the key beside the code; either spares them typing it. On Dropbox and OneDrive the new device signs in to the person's own account.
On Apple platforms and Android, saved_restore_codes().await? lists the stores coven saved, with their names, ids and provider locations, and whether each has an S3 key beside its code. The name is the last one any of the person's devices saw: a label for the list, which a rename made while none of them had the store open leaves behind. Block Store doesn't sync: each Android phone keeps its own entries, a rename or a forget reaches only that phone's, and a phone set up from one whose code is stale lists a code that fails at restore. The app passes the chosen entry to restore_from_saved_code, which finds the store by its id. forget_saved_restore_code(store) removes an entry the person no longer wants offered. An entry coven can't read is listed as unreadable, with its store id and why, so the person can forget that one too.
Ana's new laptop scans the code from her phone:
let info = decode_code_info(&scanned)?;
let metadata = info.metadata();
let mut builder = Coven::builder(layout.clone())
.synced_tables(tables())
.migrations(migrations())
.oauth_clients(oauth_clients.clone());
if let Some(access) = metadata.oauth_access {
builder.authenticate(access).await?;
}
let s3_credentials = if metadata.cloud_provider == CloudProvider::S3 {
Some(ask_for_s3_access_key().await?)
} else {
None
};
let handle = restore_from_code(builder, &scanned, s3_credentials, "Ana's laptop", |step| show_step(step), &cancel_rx)
.await?;The laptop opens the key copies sealed to Ana, loads the store, checks that the code's keys are Ana's current ones, and registers itself as a new device. An old code, whose keys have since been replaced, fails with SyncError::MemberKeysReplaced. Restoring always makes a fresh device; it doesn't rescue an old installation's unsent writes. restore_from_saved_code does the same from the saved entry.
Anyone who sees the code can open the store, given storage access, for as long as its keys are current: to coven, they are that member. Apps should blur the QR code until the person taps to show it. Coven offers no recovery from a stolen code; the household removes that member and starts over.
Adding a person: invites
Ana adds Dan to Household, on Dropbox, with Dan beside her:
- Ana enters Dan's name and Dropbox email and chooses to show the invite in person. Her phone shares the store's folder with Dan's account and shows an invite: a QR code holding the store's location, Dan's name, a member id chosen for Dan, and a one-time secret.
- Dan's phone scans it, signs in to his own Dropbox, makes Dan's member keys, and writes a join request to storage, encrypted with a key from the invite secret.
- Ana's phone, the only device holding that secret, reads the request, checks it and approves it on its own.
- Ana's phone sends Dan copies of every store key, sealed to his new keys, then claims the entry adding him.
- Dan's phone opens its keys, loads the store and registers itself.
The invite only lets Dan ask; Ana's phone lets him in. An invite has one request, so the phone Ana watched scan the code is the one let in.
When Dan isn't beside her, Ana sends the invite by message or email instead. Someone else who sees it could use it first, so her phone waits for her to approve. Dan's phone shows a six-digit confirmation number computed from his new keys, and Ana's phone shows the number of the request it read. Ana asks Dan for his number over a call or a thread she knows is his, and approves only if they match:
// Ana's phone
let (invite, _, _) = handle
.create_invite(
InviteTarget::NewMember { role: MemberRole::Member, name: "Dan".into() },
InviteAccess::ProviderAccount { email: "[email protected]".into() },
InviteDelivery::Sent { lifetime: InviteLifetime::SevenDays },
)
.await?;
// Ana's open invites are the coven_invites view, which a live query follows.
let mut invites = handle.subscribe(Invite::list);
let mut sent = false;
while let Some(open) = invites.next().await {
let found = open?.into_iter().find(|i| i.id == invite).map(|i| i.stage);
match found {
// A pass has shared the folder with Dan's account and fixed the code.
Some(InviteStage::Waiting { code, request: None }) if !sent => {
send_to_dan(&code);
sent = true;
}
// Only a request waiting for Ana is hers to decide.
Some(InviteStage::Waiting { request: Some(request), .. }) => {
// Ana asks Dan for the number his phone shows.
if confirm(&request.confirmation).await {
handle.approve_join_request(&request).await?;
} else {
handle.decline_join_request(&request).await?;
}
break;
}
_ => {}
}
}// Dan's phone
let mut builder = Coven::builder(layout.clone())
.synced_tables(tables())
.migrations(migrations())
.oauth_clients(oauth_clients.clone());
builder.authenticate(OAuthAccess::Dropbox(DropboxAccess::FullDropbox)).await?;
// The waiting screen shows Dan's number and when the next check runs; its
// Check now button calls control.check_now().
let control = JoinControl::new();
match join_with_invite(builder, &scanned, "Dan's phone", |step| show_step(step), &control).await? {
Some(handle) => show_store(handle),
None => show_declined(),
}On Dropbox, OneDrive and iCloud, coven shares a store only from the account that holds it, so the inviting admin must be that member. That is coven's rule, not every provider's: a Dropbox folder whose ACL update policy is editors lets any editor add and remove members, and CloudKit (iOS and macOS 26 and later) has an Administrator role that can add and remove participants. Whether a OneDrive editor can share further is unverified. Coven doesn't let admins grant or revoke, even where the provider would (bae-fm/coven#238). On S3 coven shares nothing: InviteAccess::S3AccessKey hands over a key made by hand in the provider's console (below).
An invite expires seven or thirty days after it was made, as the admin chose. Declining, letting it expire, or cancel_invite takes back the provider access it granted. Approving, declining and cancelling are steps of a write, approve_join_request, decline_join_request and cancel_invite, offline too. Approving returns the operation that binds Dan's pair, whose outcome coven_operations lists; declining or cancelling ends the invite in that write, and a pass replaces the request with a marker and takes back the access. The marker turns away a request sent after the invite ended, which would otherwise wait for no one. If the inviting device stops for good, removed, replaced, or its member's keys replaced, an admin's device that applies that deletes the device's waiting requests and leaves one marker beside them, never deleted, which turns away any request sent later. The share an invite grants is recorded in history before the grant, so the owner's other devices take it back when the inviting device stops for good, unless a member or another open invite still uses the account. Dan's phone keeps its new keys and request across restarts, and picks up where it left off; join_with_invite returns None when the request is declined, the invite is cancelled, or it expires. It sends no first request after the invite's expiry, which InviteInfo.expires_at shows.
While a join is under way, Dan's phone also keeps the invite code in its own keychain, never synced, and beside it his Dropbox sign-in. If Dan closes the app while he waits, the next start finds the join and resumes it without asking him for the code or a sign-in. The kept sign-in refreshes in the keychain, so a token Dropbox rejected while he waited is never used again. Only if Dropbox refuses the kept sign-in for good does resuming fail with OAuthError::Reauthorize; Dan then signs in with the access the join names, and it resumes:
// Each call consumes its builder, so each gets one configured as above.
let new_builder = || {
Coven::builder(layout.clone())
.synced_tables(tables())
.migrations(migrations())
.oauth_clients(oauth_clients.clone())
};
if let Some(join) = new_builder().unfinished_joins().await?.into_iter().next() {
// join.store_name and join.invitee come from the invite.
let store = join.metadata.store_id;
let resumed = match new_builder().resume_join(store, |step| show_step(step), &control).await {
Err(BootstrapError::OAuth(OAuthError::Reauthorize(_))) => {
let mut builder = new_builder();
builder.authenticate(join.metadata.oauth_access.expect("a sign-in join")).await?;
builder.resume_join(store, |step| show_step(step), &control).await
}
resumed => resumed,
};
match resumed? {
Some(handle) => show_store(handle),
None => show_declined(),
}
}Once Dan's phone may have sent its request, the join can't be cancelled: Ana's phone may already be approving it. control.cancel() then ends the call with BootstrapError::JoinRequestSent and keeps the join for later, as closing the app does. Before the request is sent, cancelling removes everything.
Losing everything
Ben loses every device and his restore code. An admin invites him again with InviteTarget::ReplaceMemberKeys { member }, naming his existing member id. Approving it replaces his keys: his role, circles and existing device ids stay attached to his id, writes signed with his old keys that land afterwards are refused, and his old keys count as exposed, so keys copied to them are replaced (Encryption).
If every member loses every device and restore code, the store is lost: everything is encrypted.
Replacing a member's keys
A member who suspects a leak replaces their own keys with handle.replace_member_keys(), which returns a new restore code. While it runs, app writes are refused with DbError::MemberKeysChanging; it first sends the writes already queued, while the old keys still count. Other devices still holding the old keys stop sending and report MemberKeysReplaced; they restore from the new code. The member's id, role, circles and devices stay the same. An admin can publish a prepared pair for another member with replace_member_keys_for.
Removing people and devices
remove_member(&member) publishes one entry removing the member, their devices and their circle memberships; a circle left empty is deleted at that slot. remove_device(device) removes one device and says how its member cuts that device off at the provider. set_member_role changes a role. Each is a step of a write, or a handle call that makes one.
A removed person still has the keys and data they had. Two things stop them doing more: the sequence stops counting what they send, and the provider stops letting them reach storage.
The cut-off
A removal's slot is a line in history. Writes from the removed device or member that land in later slots don't count; writes that landed before keep counting. The same line falls at a member-key replacement: later writes signed with the old keys don't count.
Ana's old phone sends write 8 while her new device claims slot 70, registering itself in the old phone's place. If write 8 lands at slot 69, it counts. At slot 71, it doesn't. Every device consumes that slot, makes no change to any row, records no lost value, and reports WritesRefused naming the entry that decided it. There is no grace period.
A device that applies its own removal, or its member's, stops syncing and tells the app.
Provider access
Removal in the sequence stops Ben's writes from counting. Taking away his storage access stops him reading anything new. That second step is provider work, recorded when the removal applies; the provider work shows in pending().
On Dropbox, OneDrive and iCloud, coven changes sharing only from the account that holds the store (above), so that member's device unshares when it applies the removal. Ben removes Dan while Ana, the folder's owner, is offline: Dan's removal applies everywhere at once, and Ana's next pass does the unsharing; her pending list shows it until it's done, and Ben's shows nothing for it, since his device can't see it happen. The owner's device takes back only a grant to that one account, keeps an account shared while another member or open invite uses it, and reports grants it can't remove alone, such as a parent folder's share or a public link, as PendingReason::AccessRemains.
These providers can't cut off one device of an account. To lock out a lost phone, the person does this; ProviderSignOut tells the app the step for Dropbox, OneDrive and iCloud, and on S3 there is none to return, because the phone shares its member's key:
| Provider | What the person does |
|---|---|
| Dropbox, OneDrive | remove the app's access from their account, then sign in again on the devices they keep |
| iCloud | remove the lost device from their Apple account |
| S3 | the member makes a new key in the provider's console, replaces their key with it, and deletes the old one there |
Removal changes no ciphertext or key anyone already has; it stops what comes next. Keys the removed member had seen are replaced before new data uses them (Encryption).
S3 access keys
On S3 each member has their own access key. The person makes every key in the provider's console and deletes it there; coven never holds the storage account's admin access. guided_access_key() fills in what the console needs: the store's bucket and the member policy.
handle.replace_access_key(new_key_id, new_secret).await?;To replace their key, a member makes a new one in the console and gives it to a device they keep, which installs it and records it. The restore code doesn't change. Their other devices take the new key with update_credentials.
When a key stops being used, because it was replaced, its member was removed, or its invite ended, coven forgets it and keeps no notice. The app should name the key then, so the person deletes it in the console; until they do, it still reaches the store.