Phase 2g: GroupKeyDistribute \u2192 encrypted post
Removes the last persona-signed direct push on the wire. Group/circle
seeds no longer travel via the 0xA0 `GroupKeyDistribute` uni-stream from
admin to member. Instead the admin publishes an encrypted post containing
the seed + metadata; each member is a recipient; the post propagates via
the normal CDN. Members decrypt with their posting secret to recover the
seed.
Eliminates the wire-level coordination signal between an admin endpoint
and each member endpoint when a group is created, a member is added, or
a key is rotated.
Core pieces:
- New `VisibilityIntent::GroupKeyDistribute` variant.
- New `types::GroupKeyDistributionContent` — JSON payload inside the
encrypted post: group_id, circle_name, epoch, group_public_key, admin,
canonical_root_post_id, group_seed.
- New `group_key_distribution` module:
- `build_distribution_post(admin, admin_secret, record, group_seed, members)`
returns `(PostId, Post, PostVisibility::Encrypted)` — wraps the CEK
per member using standard `crypto::encrypt_post`.
- `try_apply_distribution_post(s, post, visibility, our_personas)`
iterates every posting identity's secret trying to decrypt; on
success stores `group_key` + `group_seed` and returns true.
- `process_pending(s, our_personas)` scans stored
GroupKeyDistribute-intent posts and applies any we can decrypt.
Node API:
- `add_to_circle`: builds a distribution post wrapping the current seed
to just the new member, stores with intent=GroupKeyDistribute, and
propagates via `update_neighbor_manifests_as` (no direct push).
- `create_group_key_inner`: at group creation, after wrapping keys for
every non-self member, builds one distribution post addressed to all
of them and propagates through the CDN.
- `rotate_group_key`: same pattern at epoch rotation.
- New `Node::process_group_key_distributions` — scans and applies.
`sync_all` now calls it automatically so seeds take effect right after
a pull cycle.
Removals (wire-breaking; v0.6.2 already forked):
- MessageType 0xA0 (`GroupKeyDistribute`), its payload struct, the
handler in connection.rs, and `Network::push_group_key` all deleted.
ConnectionManager's `secret_seed` (network secret) is no longer used for
group-key unwrapping — that shifted to posting secrets in the apply
pass, matching the v0.6.1+ identity split where group keys are wrapped
to posting NodeIds.
Tests: new `member_decrypts_and_applies` covers a recipient decrypting +
storing the seed and a non-recipient failing to apply. Workspace
compiles clean; 118 / 118 core tests pass on a stable run (pre-existing
flaky `relay_cooldown` test with a 1ms timing window is unrelated).
This commit is contained in:
parent
2cb211eb11
commit
f88618bb6f
8 changed files with 385 additions and 132 deletions
|
|
@ -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;
|
||||
|
|
|
|||
216
crates/core/src/group_key_distribution.rs
Normal file
216
crates/core/src/group_key_distribution.rs
Normal file
|
|
@ -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<bool> {
|
||||
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<usize> {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1033,17 +1033,6 @@ 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).
|
||||
pub async fn send_social_checkin(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
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<NodeId> = 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<NodeId> = 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GroupMemberKey>,
|
||||
/// 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<PostId>,
|
||||
}
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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<PostId>,
|
||||
/// 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)]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue