itsgoin/crates/core/src/group_key_distribution.rs
Scott Reimers dfd3253734 Fix: GroupKeyDistribute admin forgery + cap concurrent port scanners
Two pre-release fixes found during audit.

1) GroupKeyDistribute admin forgery (critical)

   `group_key_distribution::try_apply_distribution_post` trusted the
   `admin` field inside the decrypted payload without verifying it
   matched the post's author. Exploit: any peer who learns a victim's
   posting NodeId (public — appears as a recipient on any DM/group
   post) and observes a target group_id in the wild could craft an
   encrypted distribution post claiming to be from the legitimate
   admin. The victim's storage uses INSERT OR REPLACE on group_keys,
   so a successful forgery would overwrite the victim's legitimate
   group key record and stored seed, breaking future rotations / key
   distributions from the real admin.

   Fix: reject the distribution post when `content.admin != post.author`.
   Added test `forged_admin_is_rejected` that seeds a legitimate
   record, attempts a forgery, and asserts the legitimate record is
   untouched.

2) Cap concurrent port-scan hole punches at 1 (bandwidth)

   `hole_punch_with_scanning` fires ~100 QUIC ClientHellos/sec for up
   to SCAN_MAX_DURATION_SECS (300s), ~1 Mbps per active scanner. With
   no cap, the growth loop / anchor referrals / replication paths
   could spawn several scanners at once and drive sustained multi-Mbps
   upload — particularly pathological on obfuscated VPNs where every
   probe stalls at a proxy timeout, explaining the reported 10 Mbps
   sustained upload after anchor connect.

   Fix: module-level `tokio::sync::Semaphore(1)` guarding entry to the
   scanning loop. Second-and-beyond callers fall back to the cheaper
   `hole_punch_parallel` (standard punching, no 100/sec port walk)
   instead of spawning another scanner. Permit is held for the scanner
   lifetime and released on return. Added unit test
   `scanner_semaphore_caps_concurrent_scans_at_one`.

Both changes leave the successful-call path untouched (single scanner
still runs; legitimate key distributions still apply). 120 / 120 core
tests pass.
2026-04-22 23:32:10 -04:00

296 lines
12 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.
//!
//! 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.
};
// 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,
};
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());
}
}