From 4a1db1ce7f86782d6e6b2155852c54afff12f2ec Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 22 Apr 2026 17:11:20 -0400 Subject: [PATCH] Core: network/posting key split + decrypt-all-personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh installs now generate two independent ed25519 keys — one as the network (QUIC) identity in identity.key, and a SEPARATE one as the default posting identity in posting_identities. They share nothing. v0.6.0 upgraders: if the default posting key equals the network key (the state Phase 4's migration left us in), rotate identity.key to a fresh random value. The old key stays in posting_identities as the default persona — peers keep seeing the same author on our posts; only the QUIC NodeId changes. A one-shot reconnect-churn on upgrade, then back to normal. Storage: - Drop seed_posting_identity_from_network (v0.6.0-specific helper) - Add count_posting_identities() Node::open_with_bind: - Load identity.key (network secret — network-only from now on) - Ensure posting_identities has at least one entry; if empty, generate an INDEPENDENT random posting key as the default - Detect default-posting-key == network-key collision and rotate identity.key, logging the migration - default_posting_id / default_posting_secret resolved from storage Decrypt: - decrypt_posts now takes &[PostingIdentity] and tries each held persona as a recipient candidate. Past DMs to any persona on this device (including ones added via Import as personas) decrypt correctly. Callers pre-load list_posting_identities() alongside group_seeds. - decrypt_just_created looks up the author's specific posting identity rather than assuming the default. Profile broadcasts (wire-level privacy): - Profile stays keyed by network NodeId — the field is load-bearing for N1/N2/N3 social routing (anchors/recent_peers/preferred_peers feed build_preferred_tree_for and peer-anchor reachability lookup). - But push_profile and InitialExchange now STRIP display_name, bio, and avatar_cid before sending, via new PublicProfile::sanitized_for_network_broadcast(). A name attached to the network id would correlate the QUIC endpoint to a human. Until v0.6.2 introduces persona-signed profile posts, peers display authors as hex. Auto-follow only the default posting id (network id is never an author, following it would be dead weight). All 111 core tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 20 ++++-- crates/core/src/network.rs | 13 ++-- crates/core/src/node.rs | 131 ++++++++++++++++++++++++---------- crates/core/src/storage.rs | 26 +------ crates/core/src/types.rs | 16 +++++ 5 files changed, 134 insertions(+), 72 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 3ae2388..1724055 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1513,7 +1513,11 @@ impl ConnectionManager { let storage = self.storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - let profile = storage.get_profile(&self.our_node_id)?; + // Profile keyed by network id (used for N1/N2/N3 routing). + // Strip persona display data before sending so peers don't learn + // a human-readable name for our network id. + let profile = storage.get_profile(&self.our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; @@ -1656,7 +1660,11 @@ impl ConnectionManager { let storage = self.storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - let profile = storage.get_profile(&self.our_node_id)?; + // Profile keyed by network id (used for N1/N2/N3 routing). + // Strip persona display data before sending so peers don't learn + // a human-readable name for our network id. + let profile = storage.get_profile(&self.our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; @@ -8391,7 +8399,9 @@ pub async fn initial_exchange_connect( let storage = storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - let profile = storage.get_profile(our_node_id)?; + // Profile keyed by network id; strip persona display before send. + let profile = storage.get_profile(our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; @@ -8470,7 +8480,9 @@ pub async fn initial_exchange_accept( let storage = storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - let profile = storage.get_profile(our_node_id)?; + // Profile keyed by network id; strip persona display before send. + let profile = storage.get_profile(our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 1d16f4a..cf87b52 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -904,12 +904,13 @@ impl Network { /// Push a profile update to all audience members (ephemeral-capable). pub async fn push_profile(&self, profile: &PublicProfile) -> usize { - // Sanitize: if public_visible=false, strip display_name/bio from pushed profile - let mut push_profile = profile.clone(); - if !profile.public_visible { - push_profile.display_name = String::new(); - push_profile.bio = String::new(); - } + // v0.6.1: profiles broadcast on the wire are keyed by the network + // NodeId. They carry ONLY routing metadata (anchors, recent_peers, + // preferred_peers) — no display name / bio / avatar. Attaching a + // human-readable name to the network id would correlate the QUIC + // endpoint to a specific person. Persona-level display data will + // travel via signed posts from v0.6.2 onward. + let push_profile = profile.sanitized_for_network_broadcast(); let payload = ProfileUpdatePayload { profiles: vec![push_profile], }; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index c0ce541..db40498 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -73,9 +73,10 @@ impl Node { let data_dir = data_dir.as_ref().to_path_buf(); std::fs::create_dir_all(&data_dir)?; - // Load or generate identity key + // Load or generate identity key (network secret — QUIC endpoint only, + // never used as content author under the v0.6.1+ clean model). let key_path = data_dir.join("identity.key"); - let (secret_key, secret_seed) = if key_path.exists() { + let (mut secret_key, mut secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() @@ -85,7 +86,7 @@ impl Node { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed = key.to_bytes(); std::fs::write(&key_path, seed)?; - info!("Generated new identity key"); + info!("Generated new network identity key"); (key, seed) }; @@ -103,6 +104,50 @@ impl Node { } } + // Ensure a default posting identity exists, INDEPENDENT of the network + // key. On a fresh install we generate a new random ed25519 key as the + // default persona. Peers who see our posts never learn our network key. + { + let s = storage.get().await; + if s.count_posting_identities()? == 0 { + let pk = iroh::SecretKey::generate(&mut rand::rng()); + let seed = pk.to_bytes(); + let nid: NodeId = *pk.public().as_bytes(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + s.upsert_posting_identity(&crate::types::PostingIdentity { + node_id: nid, + secret_seed: seed, + display_name: String::new(), + created_at: now, + })?; + s.set_default_posting_id(&nid)?; + info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)"); + } + } + + // v0.6.0 → v0.6.1 migration: if the default posting key equals the + // network key (which is what the Phase 4 migration did on upgrade from + // v0.5), rotate the network key so they become independent. The old + // key stays as the default posting identity — peers keep seeing the + // same author; only the QUIC NodeId changes. + { + let s = storage.get().await; + if let Some(default_id) = s.get_default_posting_id()? { + if let Some(default_pi) = s.get_posting_identity(&default_id)? { + if default_pi.secret_seed == secret_seed { + let new_key = iroh::SecretKey::generate(&mut rand::rng()); + let new_seed = new_key.to_bytes(); + std::fs::write(&key_path, new_seed)?; + info!("v0.6.1 migration: rotated network key to decouple from default posting key"); + secret_key = new_key; + secret_seed = new_seed; + } + } + } + } + // Open blob store let blob_store = Arc::new(BlobStore::open(&data_dir)?); @@ -117,27 +162,21 @@ impl Node { ); let node_id = network.node_id_bytes(); - // Seed the posting-identity table from the network key on first launch - // (0.6.3 migration). For upgraders, the default posting identity has - // the same key bytes as the network identity so all existing signed - // content keeps verifying. + // Resolve default posting identity (now guaranteed to exist). let (default_posting_id, default_posting_secret) = { let s = storage.get().await; - s.seed_posting_identity_from_network(&node_id, &secret_seed)?; - let default_id = s.get_default_posting_id()?.unwrap_or(node_id); - let default_seed = s.get_posting_identity(&default_id)? - .map(|pi| pi.secret_seed) - .unwrap_or(secret_seed); - (default_id, default_seed) + let default_id = s.get_default_posting_id()? + .ok_or_else(|| anyhow::anyhow!("default posting identity missing after initialization"))?; + let pi = s.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity row missing"))?; + (pi.node_id, pi.secret_seed) }; - // Auto-follow ourselves so our own posts show in the feed + // Auto-follow our default posting identity so our own posts show in + // the feed. The network NodeId is not followed — it's never an author. { let s = storage.get().await; - s.add_follow(&node_id)?; - if default_posting_id != node_id { - s.add_follow(&default_posting_id)?; - } + s.add_follow(&default_posting_id)?; } // Build the node (fast path — no network I/O beyond endpoint creation) @@ -1041,25 +1080,27 @@ impl Node { pub async fn get_feed( &self, ) -> anyhow::Result)>> { - let (raw, group_seeds) = { + let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.get_feed()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - (posts, seeds) + let personas = storage.list_posting_identities().unwrap_or_default(); + (posts, seeds, personas) }; - Ok(self.decrypt_posts(raw, &group_seeds)) + Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_all_posts( &self, ) -> anyhow::Result)>> { - let (raw, group_seeds) = { + let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.list_posts_reverse_chron()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - (posts, seeds) + let personas = storage.list_posting_identities().unwrap_or_default(); + (posts, seeds, personas) }; - Ok(self.decrypt_posts(raw, &group_seeds)) + Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_feed_page( @@ -1067,13 +1108,14 @@ impl Node { before_ms: Option, limit: usize, ) -> anyhow::Result)>> { - let (raw, group_seeds) = { + let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.get_feed_page(before_ms, limit)?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - (posts, seeds) + let personas = storage.list_posting_identities().unwrap_or_default(); + (posts, seeds, personas) }; - Ok(self.decrypt_posts(raw, &group_seeds)) + Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_all_posts_page( @@ -1081,19 +1123,23 @@ impl Node { before_ms: Option, limit: usize, ) -> anyhow::Result)>> { - let (raw, group_seeds) = { + let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.list_posts_page(before_ms, limit)?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - (posts, seeds) + let personas = storage.list_posting_identities().unwrap_or_default(); + (posts, seeds, personas) }; - Ok(self.decrypt_posts(raw, &group_seeds)) + Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } + /// Attempt to decrypt each post using all held posting identities as + /// candidate recipients. The first persona whose secret matches a + /// wrapped_key recipient wins; if none match, the post remains opaque. fn decrypt_posts( - &self, posts: Vec<(PostId, Post, PostVisibility)>, group_seeds: &std::collections::HashMap<(crate::types::GroupId, crate::types::GroupEpoch), ([u8; 32], [u8; 32])>, + personas: &[crate::types::PostingIdentity], ) -> Vec<(PostId, Post, PostVisibility, Option)> { posts .into_iter() @@ -1101,14 +1147,17 @@ impl Node { let decrypted = match &vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { - crypto::decrypt_post( - &post.content, - &self.default_posting_secret, - &self.node_id, - &post.author, - recipients, - ) - .unwrap_or(None) + personas.iter().find_map(|pi| { + crypto::decrypt_post( + &post.content, + &pi.secret_seed, + &pi.node_id, + &post.author, + recipients, + ) + .ok() + .flatten() + }) } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { group_seeds.get(&(*group_id, *epoch)) @@ -1190,6 +1239,10 @@ impl Node { .as_millis() as u64; let recent_peers = self.current_recent_peers().await; + // Profile is keyed by the network NodeId — that's how peers route to + // us. Broadcasts strip display_name / bio / avatar before going on + // the wire (see Network::push_profile). The locally stored profile + // retains the name for the user's own UI. let profile = { let storage = self.storage.get().await; let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index d969125..c68f094 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4353,31 +4353,11 @@ impl Storage { self.set_setting("active_default_posting_id", &hex::encode(node_id)) } - /// Ensure the posting_identities table has at least one entry. On first - /// launch after 0.6.3 upgrade, copies the network key from - /// `identity.key` on disk into posting_identities and sets it as default, - /// preserving signature validity of all existing content. - pub fn seed_posting_identity_from_network( - &self, - network_node_id: &NodeId, - network_secret: &[u8; 32], - ) -> anyhow::Result<()> { - let existing: i64 = self.conn.prepare( + pub fn count_posting_identities(&self) -> anyhow::Result { + let n: i64 = self.conn.prepare( "SELECT COUNT(*) FROM posting_identities", )?.query_row([], |row| row.get(0))?; - if existing == 0 { - let now = now_ms(); - self.conn.execute( - "INSERT INTO posting_identities (node_id, secret_seed, display_name, created_at) - VALUES (?1, ?2, '', ?3)", - params![network_node_id.as_slice(), network_secret.as_slice(), now as i64], - )?; - } - // Always ensure a default is set (no-op if already pointing at a valid identity). - if self.get_default_posting_id()?.is_none() { - self.set_default_posting_id(network_node_id)?; - } - Ok(()) + Ok(n as u64) } // --- File holders (flat, per-file, LRU-capped at 5) --- diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 9b40914..a6598d3 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -79,6 +79,22 @@ pub struct PublicProfile { pub avatar_cid: Option<[u8; 32]>, } +impl PublicProfile { + /// Return a copy with persona-level display data (display_name, bio, + /// avatar_cid) stripped, leaving only the routing metadata (anchors, + /// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under + /// the network NodeId; attaching a human-readable name to that key would + /// correlate the network endpoint to a specific person. Persona display + /// data will travel via signed posts from v0.6.2 onward. + pub fn sanitized_for_network_broadcast(&self) -> Self { + let mut p = self.clone(); + p.display_name = String::new(); + p.bio = String::new(); + p.avatar_cid = None; + p + } +} + fn default_true() -> bool { true }