diff --git a/Cargo.lock b/Cargo.lock index 8fdd327..cc75005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.6.1" +version = "0.6.0" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.6.1" +version = "0.6.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.6.1" +version = "0.6.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4e58bf6..b883c40 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.6.1" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 0b7c6a0..a851932 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.6.1" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 1724055..3ae2388 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1513,11 +1513,7 @@ impl ConnectionManager { let storage = self.storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - // 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 profile = storage.get_profile(&self.our_node_id)?; 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)?; @@ -1660,11 +1656,7 @@ impl ConnectionManager { let storage = self.storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - // 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 profile = storage.get_profile(&self.our_node_id)?; 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)?; @@ -8399,9 +8391,7 @@ pub async fn initial_exchange_connect( let storage = storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - // 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 profile = storage.get_profile(our_node_id)?; let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; @@ -8480,9 +8470,7 @@ pub async fn initial_exchange_accept( let storage = storage.get().await; let n1 = storage.build_n1_share()?; let n2 = storage.build_n2_share()?; - // 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 profile = storage.get_profile(our_node_id)?; 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/import.rs b/crates/core/src/import.rs index ce63862..b49e1bd 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -119,235 +119,6 @@ struct ParsedImport { skipped: usize, } -/// Staged content from a bundle, ready to write into the current identity -/// without reparenting. Posts keep their original author/post_id/signatures; -/// the bundle's posting keys become personas on this device. -struct StagedImport { - /// Posting identities to register (includes the bundle's identity.key and - /// any entries from posting_identities.json). Deduped by node_id. - posting_identities: Vec, - /// Posts in the form (post_id, Post, PostVisibility, blobs). - posts: Vec<(crate::types::PostId, Post, PostVisibility, Vec<(Attachment, Vec)>)>, - /// Follows to add to current identity's follow list. - follows: Vec, - /// Profiles to upsert (keyed by their own node_id, which becomes one of - /// our personas or a remote peer). - profiles: Vec, -} - -/// Import a bundle as personas: add the source's posting keys to our -/// `posting_identities`, and insert their posts AS-AUTHORED (no reparenting). -/// Content encrypted to any of the imported keys becomes decryptable because -/// we now hold those secrets. Idempotent — duplicate post ids / posting keys -/// are skipped via ON CONFLICT handling. -pub async fn import_as_personas( - zip_path: &Path, - storage: &StoragePool, - blob_store: &BlobStore, -) -> anyhow::Result { - let staged = { - let zip_path = zip_path.to_path_buf(); - tokio::task::spawn_blocking(move || -> anyhow::Result { - let file = std::fs::File::open(&zip_path)?; - let mut archive = zip::ZipArchive::new(file)?; - - // identity.key — the source device's primary key, which now - // becomes a posting persona on our device. - let mut posting_identities: Vec = Vec::new(); - if let Ok(mut entry) = archive.by_name("itsgoin-export/identity.key") { - let mut key_bytes = Vec::new(); - entry.read_to_end(&mut key_bytes)?; - if key_bytes.len() == 32 { - let seed: [u8; 32] = key_bytes.as_slice().try_into().unwrap(); - let sk = iroh::SecretKey::from_bytes(&seed); - let nid: NodeId = *sk.public().as_bytes(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - posting_identities.push(PostingIdentity { - node_id: nid, - secret_seed: seed, - display_name: String::new(), - created_at: now, - }); - } - } - - // posting_identities.json (v0.6+ bundles) — additional personas. - if let Ok(mut entry) = archive.by_name("itsgoin-export/posting_identities.json") { - let mut buf = String::new(); - entry.read_to_string(&mut buf)?; - if let Ok(ids) = serde_json::from_str::>(&buf) { - for id in ids { - if !posting_identities.iter().any(|p| p.node_id == id.node_id) { - posting_identities.push(id); - } - } - } - } - - // posts.json - let posts_raw: Vec = match archive.by_name("itsgoin-export/posts.json") { - Ok(mut entry) => { - let mut buf = String::new(); - entry.read_to_string(&mut buf)?; - serde_json::from_str(&buf).unwrap_or_default() - } - Err(_) => Vec::new(), - }; - - let mut staged_posts = Vec::new(); - for ep in &posts_raw { - let id_bytes = hex::decode(&ep.id).unwrap_or_default(); - let post_id: crate::types::PostId = match id_bytes.as_slice().try_into() { - Ok(id) => id, - Err(_) => continue, - }; - let author_bytes = hex::decode(&ep.author).unwrap_or_default(); - let author: NodeId = match author_bytes.as_slice().try_into() { - Ok(a) => a, - Err(_) => continue, - }; - let attachments: Vec = serde_json::from_str(&ep.attachments_json) - .unwrap_or_default(); - let vis: PostVisibility = serde_json::from_str(&ep.visibility_json) - .unwrap_or(PostVisibility::Public); - let post = Post { - author, - content: ep.content.clone(), - attachments: attachments.clone(), - timestamp_ms: ep.timestamp_ms, - }; - - // Read attached blobs - let mut blobs = Vec::new(); - for att in &attachments { - let path = format!("itsgoin-export/blobs/{}", hex::encode(att.cid)); - if let Ok(mut blob_entry) = archive.by_name(&path) { - let mut data = Vec::new(); - blob_entry.read_to_end(&mut data)?; - blobs.push((att.clone(), data)); - } - } - staged_posts.push((post_id, post, vis, blobs)); - } - - // follows.json (optional) - let follows: Vec = match archive.by_name("itsgoin-export/follows.json") { - Ok(mut entry) => { - let mut buf = String::new(); - entry.read_to_string(&mut buf)?; - serde_json::from_str::>(&buf) - .unwrap_or_default() - .into_iter() - .filter_map(|s| { - let b = hex::decode(&s).ok()?; - <[u8; 32]>::try_from(b.as_slice()).ok() - }) - .collect() - } - Err(_) => Vec::new(), - }; - - // profiles.json (optional) - let profiles: Vec = match archive.by_name("itsgoin-export/profiles.json") { - Ok(mut entry) => { - let mut buf = String::new(); - entry.read_to_string(&mut buf)?; - serde_json::from_str(&buf).unwrap_or_default() - } - Err(_) => Vec::new(), - }; - - Ok(StagedImport { - posting_identities, - posts: staged_posts, - follows, - profiles, - }) - }).await?? - }; - - // Phase 2: write into storage. - let mut imported_posts = 0usize; - let mut imported_blobs = 0usize; - let mut imported_personas = 0usize; - let mut skipped_posts = 0usize; - - // Posting identities first so the decrypt-any-persona path (feed render) - // can find them immediately after this import call returns. If the - // current device has exactly one posting identity (typically the one - // auto-created on first launch) and we're importing additional ones, - // switch the default to the first imported persona — the user's intent is - // to pick up where they left off, not to post under a fresh throwaway. - { - let s = storage.get().await; - let prior_count = s.count_posting_identities().unwrap_or(0); - for pi in &staged.posting_identities { - s.upsert_posting_identity(pi)?; - imported_personas += 1; - } - if prior_count <= 1 { - if let Some(first) = staged.posting_identities.first() { - let _ = s.set_default_posting_id(&first.node_id); - } - } - } - - // Posts + blobs. Content keeps its original post_id, author, signatures. - for (post_id, post, vis, blobs) in &staged.posts { - let s = storage.get().await; - if s.get_post(post_id).ok().flatten().is_some() { - skipped_posts += 1; - continue; - } - if crate::content::verify_post_id(post_id, post) { - // Bundle doesn't carry intent — fall back to Public for public posts, - // Friends for encrypted (closest match for re-surfacing via circles). - let intent = match &vis { - PostVisibility::Public => crate::types::VisibilityIntent::Public, - _ => crate::types::VisibilityIntent::Friends, - }; - s.store_post_with_intent(post_id, post, vis, &intent)?; - imported_posts += 1; - } else { - warn!(post_id = hex::encode(post_id), "Skipping post with invalid signature during import"); - skipped_posts += 1; - continue; - } - for (att, data) in blobs { - if !blob_store.has(&att.cid) { - blob_store.store(&att.cid, data)?; - } - s.record_blob(&att.cid, post_id, &post.author, data.len() as u64, &att.mime_type, post.timestamp_ms)?; - let _ = s.pin_blob(&att.cid); - imported_blobs += 1; - } - } - - // Follows + profiles. - { - let s = storage.get().await; - for f in &staged.follows { - let _ = s.add_follow(f); - } - for p in &staged.profiles { - let _ = s.store_profile(p); - } - } - - Ok(ImportResult { - posts_imported: imported_posts, - posts_skipped: skipped_posts, - blobs_imported: imported_blobs, - message: format!( - "Imported {} personas, {} posts ({} skipped), {} blobs", - imported_personas, imported_posts, skipped_posts, imported_blobs - ), - }) -} - /// Import public posts from a ZIP into the current identity. /// Creates new posts with the current node_id as author, preserving original timestamps. pub async fn import_public_posts( diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index cf87b52..1d16f4a 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -904,13 +904,12 @@ impl Network { /// Push a profile update to all audience members (ephemeral-capable). pub async fn push_profile(&self, profile: &PublicProfile) -> usize { - // 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(); + // 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(); + } let payload = ProfileUpdatePayload { profiles: vec![push_profile], }; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index db40498..c0ce541 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -73,10 +73,9 @@ impl Node { let data_dir = data_dir.as_ref().to_path_buf(); std::fs::create_dir_all(&data_dir)?; - // Load or generate identity key (network secret — QUIC endpoint only, - // never used as content author under the v0.6.1+ clean model). + // Load or generate identity key let key_path = data_dir.join("identity.key"); - let (mut secret_key, mut secret_seed) = if key_path.exists() { + let (secret_key, secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() @@ -86,7 +85,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 network identity key"); + info!("Generated new identity key"); (key, seed) }; @@ -104,50 +103,6 @@ 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)?); @@ -162,21 +117,27 @@ impl Node { ); let node_id = network.node_id_bytes(); - // Resolve default posting identity (now guaranteed to exist). + // 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; - 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) + 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 our default posting identity so our own posts show in - // the feed. The network NodeId is not followed — it's never an author. + // Auto-follow ourselves so our own posts show in the feed { let s = storage.get().await; - s.add_follow(&default_posting_id)?; + 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) @@ -1080,27 +1041,25 @@ impl Node { pub async fn get_feed( &self, ) -> anyhow::Result)>> { - let (raw, group_seeds, personas) = { + let (raw, group_seeds) = { let storage = self.storage.get().await; let posts = storage.get_feed()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - let personas = storage.list_posting_identities().unwrap_or_default(); - (posts, seeds, personas) + (posts, seeds) }; - Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) + Ok(self.decrypt_posts(raw, &group_seeds)) } pub async fn get_all_posts( &self, ) -> anyhow::Result)>> { - let (raw, group_seeds, personas) = { + let (raw, group_seeds) = { let storage = self.storage.get().await; let posts = storage.list_posts_reverse_chron()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); - let personas = storage.list_posting_identities().unwrap_or_default(); - (posts, seeds, personas) + (posts, seeds) }; - Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) + Ok(self.decrypt_posts(raw, &group_seeds)) } pub async fn get_feed_page( @@ -1108,14 +1067,13 @@ impl Node { before_ms: Option, limit: usize, ) -> anyhow::Result)>> { - let (raw, group_seeds, personas) = { + let (raw, group_seeds) = { 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(); - let personas = storage.list_posting_identities().unwrap_or_default(); - (posts, seeds, personas) + (posts, seeds) }; - Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) + Ok(self.decrypt_posts(raw, &group_seeds)) } pub async fn get_all_posts_page( @@ -1123,23 +1081,19 @@ impl Node { before_ms: Option, limit: usize, ) -> anyhow::Result)>> { - let (raw, group_seeds, personas) = { + let (raw, group_seeds) = { 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(); - let personas = storage.list_posting_identities().unwrap_or_default(); - (posts, seeds, personas) + (posts, seeds) }; - Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) + Ok(self.decrypt_posts(raw, &group_seeds)) } - /// 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() @@ -1147,17 +1101,14 @@ impl Node { let decrypted = match &vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { - personas.iter().find_map(|pi| { - crypto::decrypt_post( - &post.content, - &pi.secret_seed, - &pi.node_id, - &post.author, - recipients, - ) - .ok() - .flatten() - }) + crypto::decrypt_post( + &post.content, + &self.default_posting_secret, + &self.node_id, + &post.author, + recipients, + ) + .unwrap_or(None) } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { group_seeds.get(&(*group_id, *epoch)) @@ -1239,10 +1190,6 @@ 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 c68f094..d969125 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4353,11 +4353,31 @@ impl Storage { self.set_setting("active_default_posting_id", &hex::encode(node_id)) } - pub fn count_posting_identities(&self) -> anyhow::Result { - let n: i64 = self.conn.prepare( + /// 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))?; - Ok(n as u64) + 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) --- diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a6598d3..9b40914 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -79,22 +79,6 @@ 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 } diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index 87f1f4d..8a12bf5 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.6.1" +version = "0.6.0" edition = "2021" [lib] diff --git a/crates/tauri-app/gen/android/app/src/main/AndroidManifest.xml b/crates/tauri-app/gen/android/app/src/main/AndroidManifest.xml index bf78041..21b8772 100644 --- a/crates/tauri-app/gen/android/app/src/main/AndroidManifest.xml +++ b/crates/tauri-app/gen/android/app/src/main/AndroidManifest.xml @@ -16,10 +16,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/Theme.itsgoin_desktop" - android:usesCleartextTraffic="${usesCleartextTraffic}" - android:allowBackup="false" - android:fullBackupContent="false" - android:dataExtractionRules="@xml/data_extraction_rules"> + android:usesCleartextTraffic="${usesCleartextTraffic}"> - - - - - - - - - - - - - - - - - diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 15a2eb5..a805ee4 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -299,9 +299,7 @@ async fn post_to_dto( } } -/// Decrypt a just-created post for immediate display. The post was authored -/// by one of our held posting identities (default or a specific persona); -/// look up that identity's secret to decrypt. +/// Decrypt a just-created post for immediate display. async fn decrypt_just_created( node: &Node, post: &Post, @@ -310,15 +308,11 @@ async fn decrypt_just_created( match vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { - let author_identity = { - let s = node.storage.get().await; - s.get_posting_identity(&post.author).ok().flatten() - }?; itsgoin_core::crypto::decrypt_post( &post.content, - &author_identity.secret_seed, - &author_identity.node_id, - &author_identity.node_id, + &node.secret_seed_bytes(), + &node.node_id, + &node.node_id, recipients, ) .ok() @@ -1952,47 +1946,10 @@ async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option = match filter_ext.as_deref() { - Some(exts) if exts.iter().any(|e| e == "zip") => vec!["application/zip", "application/octet-stream"], - _ => vec!["*/*"], - }; - let api = app.android_fs(); - let uris = api.show_open_file_dialog(None, &mime_types, false) - .map_err(|e| format!("Open dialog failed: {}", e))?; - let uri = match uris.into_iter().next() { - Some(u) => u, - None => return Ok(None), - }; - let data = api.read(&uri).map_err(|e| format!("Read failed: {}", e))?; - // Stage in private cache so import_* can open it by path. - let cache_dir = app.path().app_cache_dir() - .map_err(|e| format!("no cache dir: {}", e))?; - std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; - // Name includes a timestamp so repeated picks don't clobber. - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let filename = match filter_ext.as_deref() { - Some(exts) if exts.iter().any(|e| e == "zip") => format!("import-{}.zip", stamp), - _ => format!("import-{}", stamp), - }; - let dest = cache_dir.join(filename); - std::fs::write(&dest, &data).map_err(|e| format!("Stage write failed: {}", e))?; - Ok(Some(dest.to_string_lossy().to_string())) - } - #[cfg(target_os = "ios")] + #[cfg(any(target_os = "android", target_os = "ios"))] { let _ = (app, title, filter_name, filter_ext); - Ok(None) + Ok(None) // Mobile: file picker not supported via this command } } @@ -2245,14 +2202,7 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result #[tauri::command] async fn reset_data(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; - // Write the sentinel at the APP-level data_dir (parent of the active - // identity's dir). The startup sentinel check runs at the same level. - // Earlier versions wrote to node.data_dir which is the identity subdir, - // making the check miss on Android. - let app_data_dir = node.data_dir.parent() - .ok_or_else(|| "no parent data dir".to_string())? - .to_path_buf(); - let sentinel = app_data_dir.join(".reset"); + let sentinel = node.data_dir.join(".reset"); std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?; Ok("Reset scheduled. Restart the app to apply.".to_string()) } @@ -2780,23 +2730,6 @@ async fn import_public_posts( Ok(result.message) } -/// Import a bundle as personas on the current identity. The bundle's posting -/// keys become additional personas; imported content keeps its original author -/// and encrypted content becomes decryptable because we now hold those keys. -#[tauri::command] -async fn import_as_personas_cmd( - state: State<'_, AppNode>, - zip_path: String, -) -> Result { - let node = get_node(&state).await; - let result = itsgoin_core::import::import_as_personas( - std::path::Path::new(&zip_path), - &node.storage, - &node.blob_store, - ).await.map_err(|e| e.to_string())?; - Ok(result.message) -} - #[tauri::command] async fn import_as_new_identity( state: State<'_, AppIdentity>, @@ -2879,24 +2812,12 @@ pub fn run() { }; std::fs::create_dir_all(&data_dir)?; - // Check for reset sentinel from previous session. A "Reset All - // Data" request wipes EVERYTHING under the app data dir so the - // next launch starts truly fresh — new network key, new posting - // key, no posts, no blobs, no identities. + // Check for reset sentinel from previous session let sentinel = data_dir.join(".reset"); if sentinel.exists() { - info!("Reset sentinel found — wiping all app data"); - if let Ok(entries) = std::fs::read_dir(&data_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.ends_with(".reset") { continue; } - if path.is_dir() { - let _ = std::fs::remove_dir_all(&path); - } else { - let _ = std::fs::remove_file(&path); - } - } - } + info!("Reset sentinel found — clearing data"); + let _ = std::fs::remove_file(data_dir.join("itsgoin.db")); + let _ = std::fs::remove_dir_all(data_dir.join("blobs")); let _ = std::fs::remove_file(&sentinel); } @@ -3071,7 +2992,6 @@ pub fn run() { import_summary, import_public_posts, import_as_new_identity, - import_as_personas_cmd, import_merge_with_key, ]) .build(tauri::generate_context!()) diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 4bf316a..4537ec4 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.6.1", + "version": "0.6.0", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/deploy.sh b/deploy.sh old mode 100755 new mode 100644 diff --git a/frontend/app.js b/frontend/app.js index 64b8aab..373b00a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2701,13 +2701,13 @@ async function doSyncAll() { } async function doSetupName() { - // Name is optional — users who want to stay anonymous can proceed with a blank field. const name = setupName.value.trim(); + if (!name) return; setupBtn.disabled = true; try { await invoke('set_display_name', { name }); setupOverlay.classList.add('hidden'); - toast(name ? 'Welcome, ' + name + '!' : 'Welcome!'); + toast('Welcome, ' + name + '!'); loadNodeInfo(); } catch (e) { toast('Error: ' + e); @@ -3797,10 +3797,9 @@ $('#import-btn').addEventListener('click', () => { -

v0.6.1 — April 22, 2026

-

Network identity is now fully separated from posting identity on every install. Plus: Android auto-backup disabled by default, Reset actually resets, import preserves your personas, and display name is optional.

+

v0.6.0 — April 21, 2026

+

The privacy architecture overhaul. Network identity is decoupled from posting identity — peers cannot correlate network traffic to a posting identity, and users can hold multiple simultaneous posting identities (personas) on one device.

@@ -128,18 +128,6 @@

Changelog

-
v0.6.1 — April 22, 2026
-
    -
  • Network ID and posting ID are now separate by default. Fresh installs generate two independent ed25519 keys. Upgraders rotate their network key on first launch; the old key stays as the default posting persona. Peers see the same author; only the QUIC endpoint changes.
  • -
  • Android auto-backup disabled. Previously the app inherited Android's default allowBackup=true, which silently uploaded identity.key (your root secret — full access to all private content) to Google Drive for any user with cloud backup enabled. That's published-to-a-third-party without asking. Now off by default, plus data_extraction_rules for Android 12+ cloud and device-transfer paths. Users who want off-device backup use Settings → Export (explicit ZIP under their control).
  • -
  • Reset All Data actually resets on Android. The sentinel file was being written to a subdir the startup check never looked at. Also wipes identity.key, WAL/SHM, and all identity subdirs — truly fresh on next launch.
  • -
  • Display name is optional on first-run. Blank field is accepted — proceed as an anonymous user. Attaching a human-readable name to a network ID would correlate the QUIC endpoint to a person; v0.6.2 will reintroduce peer-visible names via persona-signed profile posts.
  • -
  • Import preserves personas. New default import option restores source posting keys as personas on the current device; posts keep their original authors and signatures. Encrypted content imported becomes decryptable because we now hold the keys. The old "merge with key" option stays available for consolidating posts under the current default persona.
  • -
  • Android import Browse button works. Wired to Storage Access Framework's OPEN_DOCUMENT; picked file is staged in the app's private cache for the existing importer.
  • -
  • Decrypt tries all held personas. Multi-persona users (or anyone who imported prior identities as personas) can now decrypt DMs addressed to any of their keys.
  • -
  • Profile broadcasts strip persona display data. Network-keyed profile carries only routing metadata (anchors, recent_peers, preferred_peers). display_name / bio / avatar are no longer sent on the wire. Peers render author names as hex until v0.6.2 adds persona-signed profile posts.
  • -
-
v0.6.0 — April 21, 2026
  • Network fork from v0.5. The network protocol has changed enough that v0.5 and v0.6 cannot interoperate. Upgrade if you want to stay reachable.