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:
Scott Reimers 2026-04-22 23:09:19 -04:00
parent 2cb211eb11
commit f88618bb6f
8 changed files with 385 additions and 132 deletions

View file

@ -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(())
}