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
330 lines
13 KiB
Rust
330 lines
13 KiB
Rust
//! 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);
|
||
}
|
||
}
|