itsgoin/crates/core/src/group_key_distribution.rs
Scott Reimers e756fabb11 feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection
Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:03:12 -04:00

330 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Group-key distribution as an encrypted post: the group seed travels
//! 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. (No direct wire push — that
//! would signal which endpoints coordinate group membership.)
//!
//! 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, 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,
fof_gating: None,
supersedes_post_id: None,
};
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.
};
// Critical: the `admin` claimed inside the decrypted
// payload must match the post author. Without this, any
// peer who knows a member's posting id and the group's
// group_id could craft an encrypted post claiming to be
// from the admin and overwrite the member's stored group
// key (create_group_key uses INSERT OR REPLACE).
if content.admin != post.author {
tracing::warn!(
post_author = hex::encode(post.author),
claimed_admin = hex::encode(content.admin),
group_id = hex::encode(content.group_id),
"rejecting group-key-distribution post: claimed admin != post author"
);
continue;
}
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 forged_admin_is_rejected() {
// Scenario: an attacker knows the victim's posting pubkey and the
// target group_id. They craft an encrypted distribution post
// addressed to the victim, claiming themselves as the group admin.
// Without the author-vs-admin check the victim would overwrite
// their legitimate group key record.
let s = temp_storage();
let (real_admin_sec, real_admin_id) = make_keypair(1);
let (attacker_sec, attacker_id) = make_keypair(9);
let (victim_sec, victim_id) = make_keypair(2);
// Seed the victim with a legitimate group record so we can
// verify it isn't overwritten by the forgery.
let group_id = [77u8; 32];
let real_pubkey = [1u8; 32];
let real_seed = [42u8; 32];
let real_record = GroupKeyRecord {
group_id,
circle_name: "real".to_string(),
epoch: 1,
group_public_key: real_pubkey,
admin: real_admin_id,
created_at: 100,
canonical_root_post_id: None,
};
let (_, real_post, real_vis) = build_distribution_post(
&real_admin_id, &real_admin_sec, &real_record, &real_seed, &[victim_id],
).unwrap();
let victim_personas = vec![mk_persona(victim_sec, victim_id)];
assert!(try_apply_distribution_post(&s, &real_post, &real_vis, &victim_personas).unwrap());
assert_eq!(s.get_group_key(&group_id).unwrap().unwrap().admin, real_admin_id);
// Attacker authors a forgery: post.author is attacker, but the
// inner `admin` field claims to be the real admin.
let forged_content = GroupKeyDistributionContent {
group_id,
circle_name: "real".to_string(),
epoch: 2,
group_public_key: [255u8; 32],
admin: real_admin_id, // lies inside the encrypted payload
canonical_root_post_id: None,
group_seed: [0xFFu8; 32],
};
let plaintext = serde_json::to_string(&forged_content).unwrap();
let (ciphertext, wrapped) = crate::crypto::encrypt_post(
&plaintext, &attacker_sec, &attacker_id, &[victim_id],
).unwrap();
let forged_post = Post {
author: attacker_id, // real author — attacker, not admin
content: ciphertext,
attachments: vec![],
timestamp_ms: 200,
fof_gating: None,
supersedes_post_id: None,
};
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };
let applied = try_apply_distribution_post(&s, &forged_post, &forged_vis, &victim_personas).unwrap();
assert!(!applied, "forged distribution post must not be applied");
// Legitimate group key must be untouched.
let stored = s.get_group_key(&group_id).unwrap().unwrap();
assert_eq!(stored.admin, real_admin_id);
assert_eq!(stored.group_public_key, real_pubkey);
}
#[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());
}
/// A1 (multi-persona): a distribution post addressed to our SECOND
/// persona must still be applied — the trial-unwrap iterates ALL held
/// posting identities, not just the default one.
#[test]
fn second_persona_member_applies_distribution() {
let s = temp_storage();
let (admin_sec, admin_id) = make_keypair(1);
let (default_sec, default_id) = make_keypair(2); // default persona (not a member)
let (second_sec, second_id) = make_keypair(3); // second persona (the member)
let group_id = [43u8; 32];
let record = GroupKeyRecord {
group_id,
circle_name: "second".to_string(),
epoch: 1,
group_public_key: [8u8; 32],
admin: admin_id,
created_at: 100,
canonical_root_post_id: None,
};
let group_seed = [11u8; 32];
// Addressed ONLY to the second persona.
let (_pid, post, visibility) = build_distribution_post(
&admin_id, &admin_sec, &record, &group_seed, &[second_id],
).unwrap();
let personas = vec![
mk_persona(default_sec, default_id),
mk_persona(second_sec, second_id),
];
let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap();
assert!(applied, "second persona must unwrap the group seed");
assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed);
}
}