Core: network/posting key split + decrypt-all-personas

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-22 17:11:20 -04:00
parent b789ab5a19
commit 4a1db1ce7f
5 changed files with 134 additions and 72 deletions

View file

@ -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)?;

View file

@ -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],
};

View file

@ -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,28 +162,22 @@ 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)?;
}
}
// Build the node (fast path — no network I/O beyond endpoint creation)
let activity_log_ref = Arc::clone(&activity_log);
@ -1041,25 +1080,27 @@ impl Node {
pub async fn get_feed(
&self,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<u64>,
limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<u64>,
limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<String>)> {
posts
.into_iter()
@ -1101,14 +1147,17 @@ impl Node {
let decrypted = match &vis {
PostVisibility::Public => None,
PostVisibility::Encrypted { recipients } => {
personas.iter().find_map(|pi| {
crypto::decrypt_post(
&post.content,
&self.default_posting_secret,
&self.node_id,
&pi.secret_seed,
&pi.node_id,
&post.author,
recipients,
)
.unwrap_or(None)
.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();

View file

@ -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<u64> {
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) ---

View file

@ -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
}