coven/sync/
membership_ops.rs

1//! Membership operations: get members, invite, and revoke.
2//!
3//! These are the high-level orchestration functions that download the membership
4//! chain from the storage, perform the operation, and upload the results.
5
6use tracing::{info, warn};
7
8use crate::encryption::EncryptionService;
9use crate::keys::{KeyService, UserKeypair};
10
11use super::hlc::Hlc;
12use super::membership::{
13    sign_membership_entry, MemberRole, MembershipAction, MembershipChain, MembershipEntry,
14};
15use super::storage::SyncStorage;
16
17/// A member as seen by the caller.
18pub struct MemberInfo {
19    pub pubkey: String,
20    pub role: MemberRole,
21    pub is_self: bool,
22}
23
24/// Read the membership chain from the sync storage and return the current members.
25pub async fn get_members(
26    storage: &dyn SyncStorage,
27    user_pubkey: Option<&[u8]>,
28) -> Result<Vec<MemberInfo>, MembershipOpsError> {
29    let entry_keys = storage
30        .list_membership_entries()
31        .await
32        .map_err(|e| MembershipOpsError(format!("Failed to list membership entries: {e}")))?;
33
34    if entry_keys.is_empty() {
35        return Ok(Vec::new());
36    }
37
38    let chain = download_chain(storage, &entry_keys).await?;
39    let user_pubkey_hex = user_pubkey.map(hex::encode);
40
41    let current = chain.current_members();
42    let members = current
43        .into_iter()
44        .map(|(pubkey, role)| {
45            let is_self = user_pubkey_hex.as_deref() == Some(&pubkey);
46            MemberInfo {
47                pubkey,
48                role,
49                is_self,
50            }
51        })
52        .collect();
53
54    Ok(members)
55}
56
57/// Invite a member to the shared library.
58///
59/// Downloads the membership chain (bootstrapping a founder entry if needed),
60/// creates a signed Add entry, wraps the encryption key to the invitee's
61/// public key, and uploads everything to the sync storage.
62///
63/// Returns the JoinInfo for building an invite code.
64pub async fn invite_member(
65    storage: &dyn SyncStorage,
66    cloud_home: &dyn crate::storage::cloud::CloudHome,
67    user_keypair: &UserKeypair,
68    hlc: &Hlc,
69    public_key_hex: &str,
70    role: MemberRole,
71    encryption_key: &[u8; 32],
72    library_id: &str,
73    library_name: &str,
74) -> Result<crate::join_code::InviteCode, MembershipOpsError> {
75    let user_pubkey_hex = hex::encode(user_keypair.public_key);
76
77    if public_key_hex == user_pubkey_hex {
78        return Err(MembershipOpsError("Cannot invite yourself".to_string()));
79    }
80
81    // Download existing membership entries
82    let entry_keys = storage
83        .list_membership_entries()
84        .await
85        .map_err(|e| MembershipOpsError(format!("Failed to list membership entries: {e}")))?;
86
87    let mut chain = if entry_keys.is_empty() {
88        // No membership chain yet -- bootstrap with a founder entry.
89        let mut founder = MembershipEntry {
90            action: MembershipAction::Add,
91            user_pubkey: user_pubkey_hex.clone(),
92            role: MemberRole::Owner,
93            timestamp: hlc.now().to_string(),
94            author_pubkey: String::new(),
95            signature: String::new(),
96        };
97
98        sign_membership_entry(&mut founder, user_keypair);
99
100        let mut chain = MembershipChain::new();
101        chain
102            .add_entry(founder.clone())
103            .map_err(|e| MembershipOpsError(format!("Failed to create founder entry: {e}")))?;
104
105        // Upload the founder entry to the storage.
106        let founder_bytes = serde_json::to_vec(&founder)
107            .map_err(|e| MembershipOpsError(format!("Failed to serialize founder entry: {e}")))?;
108        storage
109            .put_membership_entry(&user_pubkey_hex, 1, founder_bytes)
110            .await
111            .map_err(|e| MembershipOpsError(format!("Failed to upload founder entry: {e}")))?;
112
113        info!("Bootstrapped membership chain with founder entry");
114
115        chain
116    } else {
117        download_chain(storage, &entry_keys).await?
118    };
119
120    // Create the invitation
121    let invite_ts = hlc.now().to_string();
122    let join_info = super::invite::create_invitation(
123        storage,
124        cloud_home,
125        &mut chain,
126        user_keypair,
127        public_key_hex,
128        role,
129        encryption_key,
130        &invite_ts,
131    )
132    .await
133    .map_err(|e| MembershipOpsError(format!("Failed to create invitation: {e}")))?;
134
135    info!(
136        "Invited member {}...",
137        &public_key_hex[..public_key_hex.len().min(16)]
138    );
139
140    // Sync authorized keys files for proxy auth
141    if let Err(e) = sync_authorized_keys(cloud_home, &chain).await {
142        warn!("Failed to sync authorized keys: {e}");
143    }
144
145    // Build the invite code
146    Ok(crate::join_code::InviteCode {
147        library_id: library_id.to_string(),
148        library_name: library_name.to_string(),
149        join_info,
150        owner_pubkey: user_pubkey_hex,
151    })
152}
153
154/// Remove a member from the shared library.
155///
156/// Downloads the membership chain, creates a signed Remove entry, rotates
157/// the encryption key, re-wraps it for remaining members, and returns the
158/// new encryption key bytes. The caller is responsible for persisting the
159/// new key to the keyring and updating config.
160pub async fn remove_member(
161    storage: &dyn SyncStorage,
162    cloud_home: &dyn crate::storage::cloud::CloudHome,
163    user_keypair: &UserKeypair,
164    hlc: &Hlc,
165    public_key_hex: &str,
166) -> Result<[u8; 32], MembershipOpsError> {
167    // Download existing membership entries and build the chain.
168    let entry_keys = storage
169        .list_membership_entries()
170        .await
171        .map_err(|e| MembershipOpsError(format!("Failed to list membership entries: {e}")))?;
172
173    if entry_keys.is_empty() {
174        return Err(MembershipOpsError("No membership chain exists".to_string()));
175    }
176
177    let mut chain = download_chain(storage, &entry_keys).await?;
178
179    // Revoke the member
180    let revoke_ts = hlc.now().to_string();
181    let new_key = super::invite::revoke_member(
182        storage,
183        cloud_home,
184        &mut chain,
185        user_keypair,
186        public_key_hex,
187        &revoke_ts,
188    )
189    .await
190    .map_err(|e| MembershipOpsError(format!("Failed to revoke member: {e}")))?;
191
192    info!(
193        "Revoked member {}... and rotated encryption key",
194        &public_key_hex[..public_key_hex.len().min(16)]
195    );
196
197    // Sync authorized keys files for proxy auth
198    if let Err(e) = sync_authorized_keys(cloud_home, &chain).await {
199        warn!("Failed to sync authorized keys: {e}");
200    }
201
202    Ok(new_key)
203}
204
205/// Apply the effects of a member removal: update keyring, config, and encryption service.
206/// Rotate the in-use encryption key: persist it to the keyring and swap the live
207/// encryption service. Returns the new key's fingerprint for the host to record
208/// in its own config — coven never writes the host's config.
209pub fn apply_key_rotation(
210    new_key: [u8; 32],
211    key_service: &KeyService,
212    encryption_lock: &std::sync::RwLock<EncryptionService>,
213) -> Result<String, MembershipOpsError> {
214    let new_key_hex = hex::encode(new_key);
215    key_service
216        .set_encryption_key(&new_key_hex)
217        .map_err(|e| MembershipOpsError(format!("Failed to persist new encryption key: {e}")))?;
218
219    {
220        let mut enc = encryption_lock.write().unwrap();
221        *enc = EncryptionService::from_key(new_key);
222    }
223
224    let new_fingerprint = encryption_lock.read().unwrap().fingerprint();
225    Ok(new_fingerprint)
226}
227
228/// Download and build a membership chain from the storage.
229pub(crate) async fn download_chain(
230    storage: &dyn SyncStorage,
231    entry_keys: &[(String, u64)],
232) -> Result<MembershipChain, MembershipOpsError> {
233    let mut raw_entries = Vec::new();
234    for (author, seq) in entry_keys {
235        let data = storage
236            .get_membership_entry(author, *seq)
237            .await
238            .map_err(|e| {
239                MembershipOpsError(format!(
240                    "Failed to get membership entry {author}/{seq}: {e}"
241                ))
242            })?;
243
244        let entry: MembershipEntry = serde_json::from_slice(&data).map_err(|e| {
245            MembershipOpsError(format!(
246                "Failed to parse membership entry {author}/{seq}: {e}"
247            ))
248        })?;
249        raw_entries.push(entry);
250    }
251
252    MembershipChain::from_entries(raw_entries)
253        .map_err(|e| MembershipOpsError(format!("Invalid membership chain: {e}")))
254}
255
256/// Write individual `auth/keys/{pubkey}` files for each current member.
257///
258/// This materializes the membership chain into per-key files that the proxy
259/// can read without understanding the chain format. Keys are added/removed
260/// to match the chain's current members.
261pub async fn sync_authorized_keys(
262    cloud_home: &dyn crate::storage::cloud::CloudHome,
263    chain: &MembershipChain,
264) -> Result<(), MembershipOpsError> {
265    use std::collections::HashSet;
266
267    let current: HashSet<String> = chain
268        .current_members()
269        .into_iter()
270        .map(|(pk, _)| pk)
271        .collect();
272
273    let existing = cloud_home
274        .list("auth/keys/")
275        .await
276        .map_err(|e| MembershipOpsError(format!("list auth keys: {e}")))?;
277    let existing_keys: HashSet<String> = existing
278        .iter()
279        .filter_map(|k| k.strip_prefix("auth/keys/"))
280        .map(|s| s.to_string())
281        .collect();
282
283    for pk in &current {
284        if !existing_keys.contains(pk) {
285            cloud_home
286                .write(&format!("auth/keys/{pk}"), vec![])
287                .await
288                .map_err(|e| MembershipOpsError(format!("write auth key: {e}")))?;
289        }
290    }
291
292    for pk in &existing_keys {
293        if !current.contains(pk) {
294            cloud_home
295                .delete(&format!("auth/keys/{pk}"))
296                .await
297                .map_err(|e| MembershipOpsError(format!("delete auth key: {e}")))?;
298        }
299    }
300
301    Ok(())
302}
303
304/// Membership operations error.
305#[derive(Debug, thiserror::Error)]
306#[error("{0}")]
307pub struct MembershipOpsError(pub String);