From ce4b989b1753917c58d90e038016e844fe81ea1d Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Tue, 21 Apr 2026 22:38:12 -0400 Subject: [PATCH] Phase 4 (0.6.3-beta): posting-key / network-key split (plumbing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the signing identity from the network identity. This phase ships the plumbing only — every device still has exactly one posting identity, copied from the network key on first 0.6.3 launch so all existing signed content keeps verifying. Phase 5 builds the multi-persona UX on top. Types: - New PostingIdentity struct: { node_id, secret_seed, display_name, created_at } Storage: - New posting_identities(node_id, secret_seed, display_name, created_at) table - Methods: upsert / get / list / delete posting identities; get/set default posting id (stored in settings) - seed_posting_identity_from_network: idempotent migration inserts the network key as the single posting identity and sets it default on first 0.6.3 launch Node: - default_posting_id + default_posting_secret fields populated on startup via the migration - All content signing / encryption / key wrapping now uses default_posting_secret; the old Node.secret_seed field is gone (iroh holds the network secret internally) - author field on all locally-created content is now default_posting_id (equal to node_id for upgraders until Phase 5 introduces separate personas) - Auto-follow-self covers both network_id and default_posting_id (same in 0.6.3, may diverge in 0.6.4+) Export/import: - Bundle now includes posting_identities.json in IdentityOnly / PostsWithIdentity / Everything scopes - restore_posting_identities(zip, storage) reads and upserts on import Smoke-tested: - Fresh 0.6.3 install: posting_identities seeded from network key; default set; new post's author = default_posting_id = network_id - Two-node pull sync: B pulls A's post, signature verifies across the wire - 111 core tests pass Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/export.rs | 18 ++++- crates/core/src/import.rs | 35 ++++++++- crates/core/src/node.rs | 114 +++++++++++++++++----------- crates/core/src/storage.rs | 151 ++++++++++++++++++++++++++++++++++++- crates/core/src/types.rs | 19 +++++ 5 files changed, 287 insertions(+), 50 deletions(-) diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index e27f20a..66768dd 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -138,13 +138,29 @@ pub async fn export_data( zip.write_all(manifest_json.as_bytes())?; current_size += manifest_json.len() as u64; - // Identity key + // Identity key (network key) if let Some(ref key) = identity_key { zip.start_file("itsgoin-export/identity.key", options)?; zip.write_all(key)?; current_size += key.len() as u64; } + // Posting identities (0.6.3+) — list of all posting keys held by this + // device. For upgraders the only entry is a copy of the network key; + // for multi-persona users (0.6.4+) this is the source of truth. + if scope != ExportScope::PostsOnly { + let posting_identities = { + let s = storage.get().await; + s.list_posting_identities().unwrap_or_default() + }; + if !posting_identities.is_empty() { + let pi_json = serde_json::to_string_pretty(&posting_identities)?; + zip.start_file("itsgoin-export/posting_identities.json", options)?; + zip.write_all(pi_json.as_bytes())?; + current_size += pi_json.len() as u64; + } + } + // Posts if !posts.is_empty() { let posts_json = serde_json::to_string_pretty(&posts)?; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index 771ed5e..b49e1bd 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -15,7 +15,40 @@ use crate::blob::BlobStore; use crate::content::compute_post_id; use crate::export::{ExportManifest, ExportedPost}; use crate::storage::StoragePool; -use crate::types::{Attachment, NodeId, Post, PostVisibility}; +use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity}; + +/// Extract posting_identities.json from an export ZIP and upsert each entry +/// into storage. Called during import so multi-persona users restore all +/// their posting keys. Idempotent — INSERT OR IGNORE on conflict. No-op if +/// the file is missing (pre-0.6.3 bundle). +pub async fn restore_posting_identities( + zip_path: &Path, + storage: &StoragePool, +) -> anyhow::Result { + let zip_path = zip_path.to_path_buf(); + let identities: Vec = tokio::task::spawn_blocking(move || -> anyhow::Result> { + let file = std::fs::File::open(&zip_path)?; + let mut archive = zip::ZipArchive::new(file)?; + let buf = { + let mut entry = match archive.by_name("itsgoin-export/posting_identities.json") { + Ok(e) => e, + Err(_) => return Ok(Vec::new()), + }; + let mut s = String::new(); + entry.read_to_string(&mut s)?; + s + }; + Ok(serde_json::from_str(&buf).unwrap_or_default()) + }).await??; + + let s = storage.get().await; + let mut restored = 0usize; + for id in &identities { + s.upsert_posting_identity(id)?; + restored += 1; + } + Ok(restored) +} /// What to do with the imported data. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 85c7606..c3fa57b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -26,9 +26,17 @@ pub struct Node { pub data_dir: PathBuf, pub storage: Arc, pub network: Arc, + /// Network identity — used for QUIC connections / routing. Stays hidden + /// from peers after the posting-key split ships end-to-end. pub node_id: NodeId, pub blob_store: Arc, - secret_seed: [u8; 32], + /// Active default posting identity's public NodeId. Used as `author` on + /// content signed by this device. + pub default_posting_id: NodeId, + /// Active default posting identity's secret seed. Used to sign content + /// (posts, manifests, reactions, comments, deletes) and to wrap/unwrap + /// encryption keys. + default_posting_secret: [u8; 32], bootstrap_anchors: tokio::sync::Mutex>, /// True if an anchor reported another instance of this identity is already active pub duplicate_detected: Arc, @@ -109,10 +117,27 @@ 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. + 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) + }; + // Auto-follow ourselves so our own posts show in the feed { 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) @@ -136,7 +161,8 @@ impl Node { network: Arc::clone(&network), node_id, blob_store, - secret_seed, + default_posting_id, + default_posting_secret, bootstrap_anchors: tokio::sync::Mutex::new(Vec::new()), duplicate_detected: Arc::new(AtomicBool::new(false)), profile, @@ -492,7 +518,7 @@ impl Node { /// Get the secret seed bytes (for crypto operations by consumers like Tauri) pub fn secret_seed_bytes(&self) -> [u8; 32] { - self.secret_seed + self.default_posting_secret } // --- CDN Replication Budget --- @@ -554,7 +580,7 @@ impl Node { // ---- Identity export/import ---- pub fn secret_seed(&self) -> [u8; 32] { - self.secret_seed + self.default_posting_secret } pub fn export_identity_hex(&self) -> anyhow::Result { @@ -700,7 +726,7 @@ impl Node { EncryptionMode::Public => (content, PostVisibility::Public), EncryptionMode::Recipient { cek, recipients } => { let (encrypted, wrapped_keys) = - crypto::encrypt_post_with_cek(&content, &cek, &self.secret_seed, &self.node_id, &recipients)?; + crypto::encrypt_post_with_cek(&content, &cek, &self.default_posting_secret, &self.node_id, &recipients)?; ( encrypted, PostVisibility::Encrypted { @@ -723,7 +749,7 @@ impl Node { }; let post = Post { - author: self.node_id, + author: self.default_posting_id, content: final_content, attachments, timestamp_ms: now, @@ -769,7 +795,7 @@ impl Node { let blob_header = crate::types::BlobHeader { post_id, - author: self.node_id, + author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -792,7 +818,7 @@ impl Node { let manifest = crate::types::AuthorManifest { post_id, - author: self.node_id, + author: self.default_posting_id, author_addresses: self.network.our_addresses(), created_at: now, updated_at: now, @@ -800,7 +826,7 @@ impl Node { following_posts: vec![], signature: vec![], }; - let sig = crypto::sign_manifest(&self.secret_seed, &manifest); + let sig = crypto::sign_manifest(&self.default_posting_secret, &manifest); let mut manifest = manifest; manifest.signature = sig; @@ -883,7 +909,7 @@ impl Node { } manifest.following_posts.push(new_entry.clone()); manifest.updated_at = new_timestamp_ms; - manifest.signature = crypto::sign_manifest(&self.secret_seed, &manifest); + manifest.signature = crypto::sign_manifest(&self.default_posting_secret, &manifest); let updated_json = match serde_json::to_string(&manifest) { Ok(j) => j, @@ -970,7 +996,7 @@ impl Node { PostVisibility::Encrypted { recipients } => { crypto::decrypt_post( &post.content, - &self.secret_seed, + &self.default_posting_secret, &self.node_id, &post.author, recipients, @@ -1181,7 +1207,7 @@ impl Node { PostVisibility::Public => Ok(Some(data)), PostVisibility::Encrypted { recipients } => { let cek = crypto::unwrap_cek_for_recipient( - &self.secret_seed, + &self.default_posting_secret, &self.node_id, &post.author, recipients, @@ -1500,7 +1526,7 @@ impl Node { if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { if gk.admin == self.node_id { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { - match crypto::wrap_group_key_for_member(&self.secret_seed, &node_id, &seed) { + match crypto::wrap_group_key_for_member(&self.default_posting_secret, &node_id, &seed) { Ok(wrapped) => { let mk = crate::types::GroupMemberKey { member: node_id, @@ -1572,7 +1598,7 @@ impl Node { storage.store_group_seed(&group_id, 1, &seed)?; // Wrap for ourselves - let self_wrapped = crypto::wrap_group_key_for_member(&self.secret_seed, &self.node_id, &seed)?; + let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.node_id, &seed)?; let self_mk = crate::types::GroupMemberKey { member: self.node_id, epoch: 1, @@ -1588,7 +1614,7 @@ impl Node { if *member == self.node_id { continue; } - match crypto::wrap_group_key_for_member(&self.secret_seed, member, &seed) { + match crypto::wrap_group_key_for_member(&self.default_posting_secret, member, &seed) { Ok(wrapped) => { let mk = crate::types::GroupMemberKey { member: *member, @@ -1636,7 +1662,7 @@ impl Node { if !all_members.contains(&self.node_id) { all_members.push(self.node_id); } - match crypto::rotate_group_key(&self.secret_seed, gk.epoch, &all_members) { + match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string())) } @@ -1698,7 +1724,7 @@ impl Node { .as_millis() as u64; let cp = crate::types::CircleProfile { - author: self.node_id, + author: self.default_posting_id, circle_name: circle_name.clone(), display_name, bio, @@ -1746,7 +1772,7 @@ impl Node { // Push to all connected mesh peers let payload = crate::protocol::CircleProfileUpdatePayload { - author: self.node_id, + author: self.default_posting_id, circle_name, group_id, epoch, @@ -1781,7 +1807,7 @@ impl Node { storage.delete_circle_profile(&self.node_id, &circle_name)?; crate::protocol::CircleProfileUpdatePayload { - author: self.node_id, + author: self.default_posting_id, circle_name, group_id: gk.group_id, epoch: gk.epoch, @@ -1982,11 +2008,11 @@ impl Node { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let signature = crypto::sign_delete(&self.secret_seed, post_id); + let signature = crypto::sign_delete(&self.default_posting_secret, post_id); let record = DeleteRecord { post_id: *post_id, - author: self.node_id, + author: self.default_posting_id, timestamp_ms: now, signature, }; @@ -2070,7 +2096,7 @@ impl Node { match mode { RevocationMode::SyncAccessList => { let new_wrapped = crypto::rewrap_visibility( - &self.secret_seed, + &self.default_posting_secret, &self.node_id, existing_recipients, &new_recipient_ids, @@ -2085,7 +2111,7 @@ impl Node { let update = VisibilityUpdate { post_id: *post_id, - author: self.node_id, + author: self.default_posting_id, visibility: new_vis, }; let pushed = self.network.push_visibility(&update).await; @@ -2095,7 +2121,7 @@ impl Node { RevocationMode::ReEncrypt => { let (new_content, new_wrapped) = crypto::re_encrypt_post( &post.content, - &self.secret_seed, + &self.default_posting_secret, &self.node_id, existing_recipients, &new_recipient_ids, @@ -2105,7 +2131,7 @@ impl Node { }; let new_post = Post { - author: self.node_id, + author: self.default_posting_id, content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, @@ -3541,7 +3567,7 @@ impl Node { emoji: String, private: bool, ) -> anyhow::Result { - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; @@ -3552,7 +3578,7 @@ impl Node { let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; drop(storage); - let seed = self.secret_seed; + let seed = self.default_posting_secret; let payload_json = serde_json::json!({ "emoji": emoji, "reactor": hex::encode(our_node_id), @@ -3563,7 +3589,7 @@ impl Node { None }; - let signature = crate::crypto::sign_reaction(&self.secret_seed, &our_node_id, &post_id, &emoji, now); + let signature = crate::crypto::sign_reaction(&self.default_posting_secret, &our_node_id, &post_id, &emoji, now); let reaction = crate::types::Reaction { reactor: our_node_id, emoji: emoji.clone(), @@ -3598,7 +3624,7 @@ impl Node { /// Remove a reaction from a post. pub async fn remove_reaction(&self, post_id: PostId, emoji: String) -> anyhow::Result<()> { - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let storage = self.storage.get().await; storage.remove_reaction(&our_node_id, &post_id, &emoji)?; drop(storage); @@ -3632,12 +3658,12 @@ impl Node { let post_info = storage.get_post(&post_id)?; drop(storage); - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; // If we're the author, decrypt private reactions if let Some(post) = post_info { if post.author == our_node_id { - let seed = self.secret_seed; + let seed = self.default_posting_secret; return Ok(reactions.into_iter().map(|mut r| { if let Some(ref enc) = r.encrypted_payload { if let Ok(decrypted) = crate::crypto::decrypt_private_reaction(&seed, &r.reactor, enc) { @@ -3654,7 +3680,7 @@ impl Node { /// Get reaction counts grouped by emoji for a post. pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result> { - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let storage = self.storage.get().await; let counts = storage.get_reaction_counts(&post_id, &our_node_id)?; Ok(counts) @@ -3666,8 +3692,8 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result { - let our_node_id = self.node_id; - let seed = self.secret_seed; + let our_node_id = self.default_posting_id; + let seed = self.default_posting_secret; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; @@ -3709,7 +3735,7 @@ impl Node { timestamp_ms: u64, new_content: String, ) -> anyhow::Result<()> { - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; @@ -3743,7 +3769,7 @@ impl Node { post_id: PostId, timestamp_ms: u64, ) -> anyhow::Result<()> { - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; @@ -3790,7 +3816,7 @@ impl Node { // Propagate policy change { let network = &self.network; - let our_node_id = self.node_id; + let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; @@ -3858,7 +3884,7 @@ impl Node { PostVisibility::Public => Ok(None), PostVisibility::Encrypted { recipients } => { let cek = crypto::unwrap_cek_for_recipient( - &self.secret_seed, + &self.default_posting_secret, &self.node_id, &post.author, recipients, @@ -3939,7 +3965,7 @@ impl Node { serde_json::from_str::(&json) .unwrap_or_else(|_| crate::types::BlobHeader { post_id, - author: self.node_id, + author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -3952,7 +3978,7 @@ impl Node { } else { crate::types::BlobHeader { post_id, - author: self.node_id, + author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -3978,7 +4004,7 @@ impl Node { // Propagate via BlobHeaderDiff let diff = crate::protocol::BlobHeaderDiffPayload { post_id, - author: self.node_id, + author: self.default_posting_id, ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot { post_id, slot_index: our_slot as u32, @@ -4022,7 +4048,7 @@ impl Node { serde_json::from_str::(&json) .unwrap_or_else(|_| crate::types::BlobHeader { post_id, - author: self.node_id, + author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -4035,7 +4061,7 @@ impl Node { } else { crate::types::BlobHeader { post_id, - author: self.node_id, + author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -4096,7 +4122,7 @@ impl Node { let diff = crate::protocol::BlobHeaderDiffPayload { post_id, - author: self.node_id, + author: self.default_posting_id, ops: vec![op], timestamp_ms: now, }; diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 9c064a8..d969125 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -7,9 +7,9 @@ use crate::types::{ Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile, CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId, GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord, - PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, Reaction, - ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, ThreadMeta, - VisibilityIntent, + PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, + PublicProfile, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, + ThreadMeta, VisibilityIntent, }; /// Direction for file_holders entries: whether we sent the file to this peer, @@ -401,7 +401,13 @@ impl Storage { PRIMARY KEY (post_id, recipient) ); CREATE INDEX IF NOT EXISTS idx_post_recipients_recipient - ON post_recipients(recipient);", + ON post_recipients(recipient); + CREATE TABLE IF NOT EXISTS posting_identities ( + node_id BLOB PRIMARY KEY, + secret_seed BLOB NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + );", )?; Ok(()) } @@ -4237,6 +4243,143 @@ impl Storage { Ok(()) } + // --- Posting identities (multi-persona plumbing) --- + + pub fn upsert_posting_identity(&self, id: &PostingIdentity) -> anyhow::Result<()> { + self.conn.execute( + "INSERT INTO posting_identities (node_id, secret_seed, display_name, created_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(node_id) DO UPDATE SET + display_name = excluded.display_name", + params![ + id.node_id.as_slice(), + id.secret_seed.as_slice(), + id.display_name, + id.created_at as i64, + ], + )?; + Ok(()) + } + + pub fn get_posting_identity(&self, node_id: &NodeId) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT node_id, secret_seed, display_name, created_at + FROM posting_identities WHERE node_id = ?1", + params![node_id.as_slice()], + |row| { + let nid: Vec = row.get(0)?; + let seed: Vec = row.get(1)?; + let name: String = row.get(2)?; + let ts: i64 = row.get(3)?; + Ok((nid, seed, name, ts)) + }, + ); + match result { + Ok((nid_bytes, seed_bytes, name, ts)) => { + let nid: NodeId = nid_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid posting identity node_id"))?; + let seed: [u8; 32] = seed_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid posting identity seed"))?; + Ok(Some(PostingIdentity { + node_id: nid, + secret_seed: seed, + display_name: name, + created_at: ts as u64, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn list_posting_identities(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT node_id, secret_seed, display_name, created_at + FROM posting_identities ORDER BY created_at ASC", + )?; + let rows = stmt.query_map([], |row| { + let nid: Vec = row.get(0)?; + let seed: Vec = row.get(1)?; + let name: String = row.get(2)?; + let ts: i64 = row.get(3)?; + Ok((nid, seed, name, ts)) + })?; + let mut out = Vec::new(); + for row in rows { + let (nid_bytes, seed_bytes, name, ts) = row?; + let nid: NodeId = match nid_bytes.as_slice().try_into() { + Ok(n) => n, Err(_) => continue, + }; + let seed: [u8; 32] = match seed_bytes.as_slice().try_into() { + Ok(s) => s, Err(_) => continue, + }; + out.push(PostingIdentity { + node_id: nid, + secret_seed: seed, + display_name: name, + created_at: ts as u64, + }); + } + Ok(out) + } + + pub fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM posting_identities WHERE node_id = ?1", + params![node_id.as_slice()], + )?; + Ok(()) + } + + /// Get the NodeId of the currently default posting identity. + /// Stored in `settings` under key `active_default_posting_id`. + pub fn get_default_posting_id(&self) -> anyhow::Result> { + match self.get_setting("active_default_posting_id")? { + Some(hex_str) => { + let bytes = hex::decode(&hex_str).unwrap_or_default(); + if bytes.len() == 32 { + let mut nid = [0u8; 32]; + nid.copy_from_slice(&bytes); + Ok(Some(nid)) + } else { + Ok(None) + } + } + None => Ok(None), + } + } + + pub fn set_default_posting_id(&self, node_id: &NodeId) -> anyhow::Result<()> { + 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( + "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(()) + } + // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 662cd73..9b40914 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -8,6 +8,25 @@ pub type PostId = [u8; 32]; /// A node identifier — ed25519 public key bytes (same as iroh EndpointId) pub type NodeId = [u8; 32]; +/// A posting identity: the signing key a user authors content with. +/// +/// In 0.6.3-beta, every device has exactly one posting identity, which is a +/// copy of the network key (so all existing content keeps verifying). From +/// 0.6.4-beta onward, users can hold multiple posting identities as +/// simultaneously-active personas. The network key stays hidden — peers +/// never learn which network key holds which posting key. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PostingIdentity { + /// ed25519 public key (also the author field on signed content). + pub node_id: NodeId, + /// ed25519 secret seed. + pub secret_seed: [u8; 32], + /// User-facing label for this persona. Empty string if unset. + pub display_name: String, + /// Creation timestamp (ms since epoch). + pub created_at: u64, +} + /// A public post on the network #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Post {