//! Group-key distribution as an encrypted post. //! //! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin → //! member, uni-stream) with a standard public post that carries the group //! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the //! post's CEK is wrapped per member using the admin's posting key. Members //! receive the post via normal CDN / pull paths, decrypt with their posting //! secret, and recover the seed + metadata. //! //! Removing the direct push eliminates the wire-level signal that a given //! network endpoint is coordinating group membership with another specific //! endpoint. //! //! Note: Members are identified by their **posting** NodeIds (the //! author/recipient namespace since the v0.6.1 identity split), not network //! NodeIds. The admin wraps the CEK using their default_posting_secret; the //! receiver unwraps using one of their posting identity secrets. use crate::content::compute_post_id; use crate::crypto; use crate::storage::Storage; use crate::types::{ GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId, PostVisibility, PostingIdentity, VisibilityIntent, }; /// Build an encrypted key-distribution post. Authored by the admin's /// posting identity; recipients are the member posting NodeIds. Returns /// `(PostId, Post, PostVisibility)` — caller stores with intent= /// `GroupKeyDistribute` and propagates via the normal neighbor-manifest CDN /// path. pub fn build_distribution_post( admin: &NodeId, admin_secret: &[u8; 32], record: &GroupKeyRecord, group_seed: &[u8; 32], members: &[NodeId], ) -> anyhow::Result<(PostId, Post, PostVisibility)> { let content = GroupKeyDistributionContent { group_id: record.group_id, circle_name: record.circle_name.clone(), epoch: record.epoch, group_public_key: record.group_public_key, admin: *admin, canonical_root_post_id: record.canonical_root_post_id, group_seed: *group_seed, }; let plaintext = serde_json::to_string(&content)?; // Wrap the CEK to each member (their posting pubkey). let (ciphertext_b64, wrapped_keys) = crypto::encrypt_post(&plaintext, admin_secret, admin, members)?; let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let post = Post { author: *admin, content: ciphertext_b64, attachments: vec![], timestamp_ms, }; let post_id = compute_post_id(&post); let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; Ok((post_id, post, visibility)) } /// Attempt to decrypt + apply a stored GroupKeyDistribute post using each /// posting identity's secret in turn. Returns `Ok(true)` on successful /// apply, `Ok(false)` if none of our personas were recipients (or content /// was malformed, or the seed had already been stored), `Err` on hard /// errors during storage. pub fn try_apply_distribution_post( s: &Storage, post: &Post, visibility: &PostVisibility, our_personas: &[PostingIdentity], ) -> anyhow::Result { let wrapped_keys = match visibility { PostVisibility::Encrypted { recipients } => recipients, _ => return Ok(false), // Only Encrypted posts can carry seeds. }; for persona in our_personas { match crypto::decrypt_post( &post.content, &persona.secret_seed, &persona.node_id, &post.author, wrapped_keys, ) { Ok(Some(plaintext)) => { let content: GroupKeyDistributionContent = match serde_json::from_str(&plaintext) { Ok(c) => c, Err(_) => continue, // Bad payload — try next persona. }; // Critical: the `admin` claimed inside the decrypted // payload must match the post author. Without this, any // peer who knows a member's posting id and the group's // group_id could craft an encrypted post claiming to be // from the admin and overwrite the member's stored group // key (create_group_key uses INSERT OR REPLACE). if content.admin != post.author { tracing::warn!( post_author = hex::encode(post.author), claimed_admin = hex::encode(content.admin), group_id = hex::encode(content.group_id), "rejecting group-key-distribution post: claimed admin != post author" ); continue; } apply_content(s, &content)?; return Ok(true); } Ok(None) | Err(_) => continue, } } Ok(false) } fn apply_content(s: &Storage, content: &GroupKeyDistributionContent) -> anyhow::Result<()> { let record = GroupKeyRecord { group_id: content.group_id, circle_name: content.circle_name.clone(), epoch: content.epoch, group_public_key: content.group_public_key, admin: content.admin, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0), canonical_root_post_id: content.canonical_root_post_id, }; s.create_group_key(&record, Some(&content.group_seed))?; s.store_group_seed(&content.group_id, content.epoch, &content.group_seed)?; Ok(()) } /// Scan stored posts with `VisibilityIntent::GroupKeyDistribute` and apply /// any that one of our posting identities can decrypt. Intended to run /// after a pull-sync so newly-received distribution posts take effect /// immediately. pub fn process_pending( s: &Storage, our_personas: &[PostingIdentity], ) -> anyhow::Result { // Cheap scan: iterate all posts, filter by intent. The table is small // in practice (few groups × few epochs). let all = s.list_posts_with_visibility()?; let mut applied = 0; for (id, post, visibility) in all { let intent = s.get_post_intent(&id)?; if !matches!(intent, Some(VisibilityIntent::GroupKeyDistribute)) { continue; } if try_apply_distribution_post(s, &post, &visibility, our_personas)? { applied += 1; } } Ok(applied) } #[cfg(test)] mod tests { use super::*; use crate::storage::Storage; use ed25519_dalek::SigningKey; fn temp_storage() -> Storage { Storage::open(":memory:").unwrap() } fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { let seed = [seed_byte; 32]; let signing_key = SigningKey::from_bytes(&seed); let public = signing_key.verifying_key(); (seed, *public.as_bytes()) } fn mk_persona(seed: [u8; 32], node_id: NodeId) -> PostingIdentity { PostingIdentity { node_id, secret_seed: seed, display_name: String::new(), created_at: 0, } } #[test] fn forged_admin_is_rejected() { // Scenario: an attacker knows the victim's posting pubkey and the // target group_id. They craft an encrypted distribution post // addressed to the victim, claiming themselves as the group admin. // Without the author-vs-admin check the victim would overwrite // their legitimate group key record. let s = temp_storage(); let (real_admin_sec, real_admin_id) = make_keypair(1); let (attacker_sec, attacker_id) = make_keypair(9); let (victim_sec, victim_id) = make_keypair(2); // Seed the victim with a legitimate group record so we can // verify it isn't overwritten by the forgery. let group_id = [77u8; 32]; let real_pubkey = [1u8; 32]; let real_seed = [42u8; 32]; let real_record = GroupKeyRecord { group_id, circle_name: "real".to_string(), epoch: 1, group_public_key: real_pubkey, admin: real_admin_id, created_at: 100, canonical_root_post_id: None, }; let (_, real_post, real_vis) = build_distribution_post( &real_admin_id, &real_admin_sec, &real_record, &real_seed, &[victim_id], ).unwrap(); let victim_personas = vec![mk_persona(victim_sec, victim_id)]; assert!(try_apply_distribution_post(&s, &real_post, &real_vis, &victim_personas).unwrap()); assert_eq!(s.get_group_key(&group_id).unwrap().unwrap().admin, real_admin_id); // Attacker authors a forgery: post.author is attacker, but the // inner `admin` field claims to be the real admin. let forged_content = GroupKeyDistributionContent { group_id, circle_name: "real".to_string(), epoch: 2, group_public_key: [255u8; 32], admin: real_admin_id, // lies inside the encrypted payload canonical_root_post_id: None, group_seed: [0xFFu8; 32], }; let plaintext = serde_json::to_string(&forged_content).unwrap(); let (ciphertext, wrapped) = crate::crypto::encrypt_post( &plaintext, &attacker_sec, &attacker_id, &[victim_id], ).unwrap(); let forged_post = Post { author: attacker_id, // real author — attacker, not admin content: ciphertext, attachments: vec![], timestamp_ms: 200, }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; let applied = try_apply_distribution_post(&s, &forged_post, &forged_vis, &victim_personas).unwrap(); assert!(!applied, "forged distribution post must not be applied"); // Legitimate group key must be untouched. let stored = s.get_group_key(&group_id).unwrap().unwrap(); assert_eq!(stored.admin, real_admin_id); assert_eq!(stored.group_public_key, real_pubkey); } #[test] fn member_decrypts_and_applies() { let s = temp_storage(); let (admin_sec, admin_id) = make_keypair(1); let (member_sec, member_id) = make_keypair(2); let (nonmember_sec, nonmember_id) = make_keypair(3); let group_id = [42u8; 32]; let group_pubkey = [7u8; 32]; let group_seed = [9u8; 32]; let record = GroupKeyRecord { group_id, circle_name: "fam".to_string(), epoch: 1, group_public_key: group_pubkey, admin: admin_id, created_at: 100, canonical_root_post_id: None, }; let (_pid, post, visibility) = build_distribution_post( &admin_id, &admin_sec, &record, &group_seed, &[member_id], ).unwrap(); // Member applies successfully. let member_personas = vec![mk_persona(member_sec, member_id)]; let applied = try_apply_distribution_post(&s, &post, &visibility, &member_personas).unwrap(); assert!(applied); let stored = s.get_group_key(&group_id).unwrap().unwrap(); assert_eq!(stored.circle_name, "fam"); let seed = s.get_group_seed(&group_id, 1).unwrap().unwrap(); assert_eq!(seed, group_seed); // Non-member can't. let s2 = temp_storage(); let nonmember_personas = vec![mk_persona(nonmember_sec, nonmember_id)]; let applied2 = try_apply_distribution_post(&s2, &post, &visibility, &nonmember_personas).unwrap(); assert!(!applied2); assert!(s2.get_group_key(&group_id).unwrap().is_none()); } }