diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index abe2b14..d826185 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -15,7 +15,7 @@ use crate::protocol::{ AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload, + CircleProfileUpdatePayload, GroupKeyRequestPayload, GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, @@ -5117,51 +5117,6 @@ impl ConnectionManager { "Received social disconnect notice" ); } - MessageType::GroupKeyDistribute => { - let payload: GroupKeyDistributePayload = read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - - // Verify the sender is the admin - if payload.admin != remote_node_id { - warn!(peer = hex::encode(remote_node_id), "GroupKeyDistribute from non-admin, ignoring"); - } else { - let storage = cm.storage.get().await; - let record = crate::types::GroupKeyRecord { - group_id: payload.group_id, - circle_name: payload.circle_name.clone(), - epoch: payload.epoch, - group_public_key: payload.group_public_key, - admin: payload.admin, - created_at: now_ms(), - canonical_root_post_id: payload.canonical_root_post_id, - }; - let _ = storage.create_group_key(&record, None); - - // Find our wrapped key and unwrap the group seed - for mk in &payload.member_keys { - let _ = storage.store_group_member_key(&payload.group_id, mk); - if mk.member == cm.our_node_id { - match crypto::unwrap_group_key( - &cm.secret_seed, - &payload.admin, - &mk.wrapped_group_key, - ) { - Ok(seed) => { - let _ = storage.store_group_seed(&payload.group_id, payload.epoch, &seed); - info!( - circle = %payload.circle_name, - epoch = payload.epoch, - "Received and unwrapped group key" - ); - } - Err(e) => { - warn!(error = %e, "Failed to unwrap group key"); - } - } - } - } - } - } MessageType::CircleProfileUpdate => { let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs new file mode 100644 index 0000000..07cac40 --- /dev/null +++ b/crates/core/src/group_key_distribution.rs @@ -0,0 +1,216 @@ +//! 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. + }; + 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 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()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9ded878..6fca83f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod connection; pub mod content; pub mod control; pub mod crypto; +pub mod group_key_distribution; pub mod http; pub mod export; pub mod identity; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 4b54188..114ab41 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -1033,18 +1033,7 @@ impl Network { } } -/// Push a group key to a specific peer (uni-stream). - pub async fn push_group_key( - &self, - peer: &NodeId, - payload: &crate::protocol::GroupKeyDistributePayload, - ) -> bool { - self.send_to_peer_uni(peer, MessageType::GroupKeyDistribute, payload) - .await - .is_ok() - } - - /// Send a social checkin to a peer (persistent if available, ephemeral otherwise). +/// Send a social checkin to a peer (persistent if available, ephemeral otherwise). pub async fn send_social_checkin( &self, peer_id: &NodeId, diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 98ba145..363402b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1072,9 +1072,13 @@ impl Node { VisibilityIntent::Friends => storage.list_public_follows(), VisibilityIntent::Circle(name) => storage.get_circle_members(name), VisibilityIntent::Direct(ids) => Ok(ids.clone()), - // Control and Profile posts are always Public on the wire; they - // never go through encryption recipient resolution. - VisibilityIntent::Control | VisibilityIntent::Profile => Ok(vec![]), + // Control / Profile posts are always Public on the wire; + // GroupKeyDistribute posts build their own recipient list in + // `group_key_distribution::build_distribution_post`. None of + // the three use this resolver. + VisibilityIntent::Control + | VisibilityIntent::Profile + | VisibilityIntent::GroupKeyDistribute => Ok(vec![]), } } @@ -1700,32 +1704,48 @@ impl Node { storage.add_circle_member(&circle_name, &node_id)?; } - // Wrap current group key for new member and distribute - let distribute_payload = { + // v0.6.2: distribute the seed via an encrypted key-distribution + // post (CDN-propagated), replacing the direct GroupKeyDistribute + // push. Only the admin (holder of the group seed) does this. + let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { - if gk.admin == self.node_id { + if gk.admin == self.default_posting_id { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { - match crypto::wrap_group_key_for_member(&self.default_posting_secret, &node_id, &seed) { - Ok(wrapped) => { - let mk = crate::types::GroupMemberKey { + // Record our own wrapped member key locally (so we + // still track membership in group_member_keys for + // rotation math). + if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( + &self.default_posting_secret, &node_id, &seed, + ) { + let _ = storage.store_group_member_key( + &gk.group_id, + &crate::types::GroupMemberKey { member: node_id, epoch: gk.epoch, - wrapped_group_key: wrapped, - }; - let _ = storage.store_group_member_key(&gk.group_id, &mk); - Some(crate::protocol::GroupKeyDistributePayload { - group_id: gk.group_id, - circle_name: circle_name.clone(), - epoch: gk.epoch, - group_public_key: gk.group_public_key, - admin: self.node_id, - member_keys: vec![mk], - canonical_root_post_id: gk.canonical_root_post_id, - }) + wrapped_group_key: wrapped_new, + }, + ); + } + + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &gk, + &seed, + &[node_id], + ) { + Ok((post_id, post, visibility)) => { + storage.store_post_with_intent( + &post_id, + &post, + &visibility, + &VisibilityIntent::GroupKeyDistribute, + )?; + Some((post_id, post.timestamp_ms, self.default_posting_id, self.default_posting_secret)) } Err(e) => { - warn!(error = %e, "Failed to wrap group key for new member"); + warn!(error = %e, "failed to build key-distribution post"); None } } @@ -1734,8 +1754,8 @@ impl Node { } else { None } }; - if let Some(payload) = distribute_payload { - self.network.push_group_key(&node_id, &payload).await; + if let Some((post_id, ts, posting_id, posting_secret)) = post_to_propagate { + self.update_neighbor_manifests_as(&posting_id, &posting_secret, &post_id, ts).await; } Ok(()) @@ -1866,6 +1886,16 @@ impl Node { // ---- end Groups ---- + /// Scan any newly-received `VisibilityIntent::GroupKeyDistribute` posts + /// and apply ones we can decrypt with one of our posting identities. + /// Intended to run after a sync pass so group seeds propagate to members + /// without a direct push. Returns the count of applied distributions. + pub async fn process_group_key_distributions(&self) -> anyhow::Result { + let storage = self.storage.get().await; + let personas = storage.list_posting_identities()?; + crate::group_key_distribution::process_pending(&*storage, &personas) + } + /// Shared group-key creation used by both circles (canonical_root=None) /// and groups (canonical_root=Some). async fn create_group_key_inner( @@ -1902,38 +1932,54 @@ impl Node { }; storage.store_group_member_key(&group_id, &self_mk)?; - // Wrap for existing circle members and distribute - let members = storage.get_circle_members(circle_name)?; - drop(storage); + // Wrap for existing circle members (if any) and distribute the seed + // via a single encrypted key-distribution post. v0.6.2 replaces the + // per-member uni-stream GroupKeyDistribute push with this + // CDN-propagated post (one post per epoch, recipients = all non-self + // members). + let other_members: Vec = storage.get_circle_members(circle_name)? + .into_iter() + .filter(|m| *m != self.node_id) + .collect(); - for member in &members { - if *member == self.node_id { - continue; - } - match crypto::wrap_group_key_for_member(&self.default_posting_secret, member, &seed) { - Ok(wrapped) => { - let mk = crate::types::GroupMemberKey { + for member in &other_members { + if let Ok(wrapped) = crypto::wrap_group_key_for_member( + &self.default_posting_secret, member, &seed, + ) { + let _ = storage.store_group_member_key( + &group_id, + &crate::types::GroupMemberKey { member: *member, epoch: 1, wrapped_group_key: wrapped, - }; + }, + ); + } + } + drop(storage); + + if !other_members.is_empty() { + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &record, + &seed, + &other_members, + ) { + Ok((post_id, post, visibility)) => { + let ts = post.timestamp_ms; { let storage = self.storage.get().await; - let _ = storage.store_group_member_key(&group_id, &mk); + storage.store_post_with_intent( + &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, + )?; } - let payload = crate::protocol::GroupKeyDistributePayload { - group_id, - circle_name: circle_name.to_string(), - epoch: 1, - group_public_key: pubkey, - admin: self.node_id, - member_keys: vec![mk], - canonical_root_post_id, - }; - self.network.push_group_key(member, &payload).await; + self.update_neighbor_manifests_as( + &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + ).await; } Err(e) => { - warn!(member = hex::encode(member), error = %e, "Failed to wrap group key for member"); + warn!(error = %e, "failed to build key-distribution post"); } } } @@ -1981,21 +2027,50 @@ impl Node { } } - // Distribute to each member - for mk in &member_keys { - if mk.member == self.node_id { - continue; - } - let payload = crate::protocol::GroupKeyDistributePayload { + // v0.6.2: distribute the new seed via an encrypted + // key-distribution post instead of per-member unicast pushes. + let recipients: Vec = member_keys + .iter() + .map(|mk| mk.member) + .filter(|m| *m != self.default_posting_id) + .collect(); + + if !recipients.is_empty() { + let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, - admin: self.node_id, - member_keys: vec![mk.clone()], + admin: self.default_posting_id, + 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: canonical_root, }; - self.network.push_group_key(&mk.member, &payload).await; + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &record, + &new_seed, + &recipients, + ) { + Ok((post_id, post, visibility)) => { + let ts = post.timestamp_ms; + { + let storage = self.storage.get().await; + let _ = storage.store_post_with_intent( + &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, + ); + } + self.update_neighbor_manifests_as( + &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + ).await; + } + Err(e) => { + warn!(error = %e, "failed to build rotate distribution post"); + } + } } info!(circle = %circle_name, epoch = new_epoch, "Rotated group key"); @@ -2887,6 +2962,11 @@ impl Node { "Pull complete: {} posts from {} peers", stats.posts_received, stats.peers_pulled ); + // v0.6.2: apply any newly-received key-distribution posts so group + // seeds propagate automatically after sync. + if let Ok(n) = self.process_group_key_distributions().await { + if n > 0 { info!(applied = n, "Applied group key distributions"); } + } Ok(()) } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 99fb4d1..0221c3c 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -50,7 +50,8 @@ pub enum MessageType { ManifestRefreshResponse = 0x93, ManifestPush = 0x94, // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. - GroupKeyDistribute = 0xA0, + // 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel + // as encrypted posts via the CDN. See `group_key_distribution` module. GroupKeyRequest = 0xA1, GroupKeyResponse = 0xA2, RelayIntroduce = 0xB0, @@ -102,7 +103,6 @@ impl MessageType { 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), - 0xA0 => Some(Self::GroupKeyDistribute), 0xA1 => Some(Self::GroupKeyRequest), 0xA2 => Some(Self::GroupKeyResponse), 0xB0 => Some(Self::RelayIntroduce), @@ -386,21 +386,9 @@ pub struct ManifestPushEntry { // --- Group key distribution payloads --- -/// Admin pushes wrapped group key to a member (uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyDistributePayload { - pub group_id: GroupId, - pub circle_name: String, - pub epoch: GroupEpoch, - pub group_public_key: [u8; 32], - pub admin: NodeId, - pub member_keys: Vec, - /// v0.6.2: when set, this record is a group rooted at the given public - /// post. Absent on v0.6.1 nodes — deserializes to `None` and behaves - /// like a traditional circle. - #[serde(default)] - pub canonical_root_post_id: Option, -} +// GroupKeyDistributePayload (v0.6.1) retired: group seeds now travel as +// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See +// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`. /// Member requests current group key (bi-stream request) #[derive(Debug, Serialize, Deserialize)] @@ -757,7 +745,6 @@ mod tests { MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::GroupKeyDistribute, MessageType::GroupKeyRequest, MessageType::GroupKeyResponse, MessageType::RelayIntroduce, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 77bd92f..ece69f4 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -247,6 +247,11 @@ pub enum VisibilityIntent { Control, /// Persona profile post (display_name, bio, avatar). Profile, + /// Encrypted distribution of a group/circle seed to that group's + /// members. Replaces the v0.6.1 `GroupKeyDistribute` wire push with a + /// standard encrypted post that propagates via the CDN. Members + /// decrypt with their posting secret to recover the seed. + GroupKeyDistribute, } /// Content payload of a `VisibilityIntent::Profile` post — persona display @@ -266,6 +271,25 @@ pub struct ProfilePostContent { pub signature: Vec, } +/// Content payload of a `VisibilityIntent::GroupKeyDistribute` post. +/// Wrapped inside a standard `PostVisibility::Encrypted` envelope — members +/// decrypt via `crypto::decrypt_post` with their posting secret, then parse +/// this struct to recover the group seed and metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupKeyDistributionContent { + pub group_id: GroupId, + pub circle_name: String, + pub epoch: GroupEpoch, + pub group_public_key: [u8; 32], + pub admin: NodeId, + #[serde(default)] + pub canonical_root_post_id: Option, + /// The raw group seed (32 bytes). This is the sensitive field — its + /// confidentiality is protected by the enclosing encrypted post, which + /// is wrapped to each member's posting public key. + pub group_seed: [u8; 32], +} + /// Content payload of a `VisibilityIntent::Control` post, serialized as JSON /// into the post's content field. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 154bcf2..7f2368d 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -240,6 +240,7 @@ async fn post_to_dto( VisibilityIntent::Direct(_) => "direct".to_string(), VisibilityIntent::Control => "control".to_string(), VisibilityIntent::Profile => "profile".to_string(), + VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(), }, _ => "unknown".to_string(), }