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

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