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:
parent
b789ab5a19
commit
4a1db1ce7f
5 changed files with 134 additions and 72 deletions
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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 } => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue