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

@ -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;