Compare commits

...

3 commits

Author SHA1 Message Date
Scott Reimers
12e0d4eccc v0.6.1 release: version bump + changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:57:28 -04:00
Scott Reimers
7e1e1dd738 Platform: Reset wipe, empty name, Android browse + backup-off, import as personas
Reset All Data:
- Sentinel now written at the app-level data_dir instead of the
  active identity's subdir. On Android the subdir path was never
  checked at startup, so reset silently did nothing.
- On detection, wipe EVERYTHING under the app data_dir: identity.key,
  itsgoin.db + WAL + SHM, blobs, all identity subdirs. Next launch
  is truly fresh — new network key, new posting key, no prior data.

First-run name:
- Display name is optional. Blank submits as anonymous.
- First-run modal + profile overlay placeholder updated to say
  "Display name (optional)".

Android file picker:
- pick_file on Android now uses tauri-plugin-android-fs'
  show_open_file_dialog (Storage Access Framework OPEN_DOCUMENT).
  Read the picked URI's bytes, stage them in the app's private cache
  as a timestamped file, return the staged path so existing
  import_* code can read it as a regular filesystem path.
- Zip filter passes application/zip + application/octet-stream (some
  file providers report the latter for .zip).

Android auto-backup off:
- AndroidManifest: allowBackup="false", fullBackupContent="false",
  dataExtractionRules pointing at new data_extraction_rules.xml
- New data_extraction_rules.xml excludes all domains from both
  cloud-backup and device-transfer. Prior default (allowBackup=true)
  silently replicated identity.key to Google Drive for any user with
  cloud backup on — which effectively published the root secret to
  a third party without asking. Users who want off-device backup use
  Settings -> Export (explicit zip they control).

Import as personas:
- New import_as_personas function in core/import.rs + new
  import_as_personas_cmd Tauri IPC.
- Reads identity.key from the bundle and adds it to posting_identities
  as a persona. Also reads posting_identities.json (v0.6+ bundles)
  and adds each entry. Dedupes by node_id.
- Posts stay AS-AUTHORED — original post_id, original author,
  original signatures, original wrapped_key recipients. No
  re-encryption. Content encrypted to any of the imported keys
  becomes decryptable because we now hold the secrets.
- Blobs, follows, profiles copied across.
- If current device has <=1 posting identity (the fresh-install one)
  and the bundle brings more, auto-switch the default to the first
  imported persona. Covers first-run-then-import flow cleanly.

Import wizard UI:
- New default option: "Restore as personas" — posts keep original
  authors; source's keys become personas you can post as.
- Old "Merge with decryption key" retained as "Consolidate under
  current default persona (requires source key)" for the case where
  a user intentionally abandons a persona.
- "Public posts only" and "Add as separate identity" retained.

deploy.sh made executable (chmod +x tracked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:40:21 -04:00
Scott Reimers
4a1db1ce7f 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>
2026-04-22 17:11:20 -04:00
18 changed files with 526 additions and 108 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hex", "hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2767,7 +2767,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
[[bin]] [[bin]]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -1513,7 +1513,11 @@ impl ConnectionManager {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let n1 = storage.build_n1_share()?; let n1 = storage.build_n1_share()?;
let n2 = storage.build_n2_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 deletes = storage.list_delete_records()?;
let post_ids = storage.list_post_ids()?; let post_ids = storage.list_post_ids()?;
let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; 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 storage = self.storage.get().await;
let n1 = storage.build_n1_share()?; let n1 = storage.build_n1_share()?;
let n2 = storage.build_n2_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 deletes = storage.list_delete_records()?;
let post_ids = storage.list_post_ids()?; let post_ids = storage.list_post_ids()?;
let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; 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 storage = storage.get().await;
let n1 = storage.build_n1_share()?; let n1 = storage.build_n1_share()?;
let n2 = storage.build_n2_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 deletes = storage.list_delete_records()?;
let post_ids = storage.list_post_ids()?; let post_ids = storage.list_post_ids()?;
let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; 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 storage = storage.get().await;
let n1 = storage.build_n1_share()?; let n1 = storage.build_n1_share()?;
let n2 = storage.build_n2_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 deletes = storage.list_delete_records()?;
let post_ids = storage.list_post_ids()?; let post_ids = storage.list_post_ids()?;
let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; let peer_addresses = storage.build_peer_addresses_for(our_node_id)?;

View file

@ -119,6 +119,235 @@ struct ParsedImport {
skipped: usize, 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<PostingIdentity>,
/// Posts in the form (post_id, Post, PostVisibility, blobs).
posts: Vec<(crate::types::PostId, Post, PostVisibility, Vec<(Attachment, Vec<u8>)>)>,
/// Follows to add to current identity's follow list.
follows: Vec<NodeId>,
/// Profiles to upsert (keyed by their own node_id, which becomes one of
/// our personas or a remote peer).
profiles: Vec<crate::types::PublicProfile>,
}
/// 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<ImportResult> {
let staged = {
let zip_path = zip_path.to_path_buf();
tokio::task::spawn_blocking(move || -> anyhow::Result<StagedImport> {
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<PostingIdentity> = 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::<Vec<PostingIdentity>>(&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<ExportedPost> = 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<Attachment> = 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<NodeId> = 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::<Vec<String>>(&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<crate::types::PublicProfile> = 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. /// Import public posts from a ZIP into the current identity.
/// Creates new posts with the current node_id as author, preserving original timestamps. /// Creates new posts with the current node_id as author, preserving original timestamps.
pub async fn import_public_posts( pub async fn import_public_posts(

View file

@ -904,12 +904,13 @@ impl Network {
/// Push a profile update to all audience members (ephemeral-capable). /// Push a profile update to all audience members (ephemeral-capable).
pub async fn push_profile(&self, profile: &PublicProfile) -> usize { pub async fn push_profile(&self, profile: &PublicProfile) -> usize {
// Sanitize: if public_visible=false, strip display_name/bio from pushed profile // v0.6.1: profiles broadcast on the wire are keyed by the network
let mut push_profile = profile.clone(); // NodeId. They carry ONLY routing metadata (anchors, recent_peers,
if !profile.public_visible { // preferred_peers) — no display name / bio / avatar. Attaching a
push_profile.display_name = String::new(); // human-readable name to the network id would correlate the QUIC
push_profile.bio = String::new(); // 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 { let payload = ProfileUpdatePayload {
profiles: vec![push_profile], profiles: vec![push_profile],
}; };

View file

@ -73,9 +73,10 @@ impl Node {
let data_dir = data_dir.as_ref().to_path_buf(); let data_dir = data_dir.as_ref().to_path_buf();
std::fs::create_dir_all(&data_dir)?; 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 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 key_bytes = std::fs::read(&key_path)?;
let bytes: [u8; 32] = key_bytes let bytes: [u8; 32] = key_bytes
.try_into() .try_into()
@ -85,7 +86,7 @@ impl Node {
let key = iroh::SecretKey::generate(&mut rand::rng()); let key = iroh::SecretKey::generate(&mut rand::rng());
let seed = key.to_bytes(); let seed = key.to_bytes();
std::fs::write(&key_path, seed)?; std::fs::write(&key_path, seed)?;
info!("Generated new identity key"); info!("Generated new network identity key");
(key, seed) (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 // Open blob store
let blob_store = Arc::new(BlobStore::open(&data_dir)?); let blob_store = Arc::new(BlobStore::open(&data_dir)?);
@ -117,28 +162,22 @@ impl Node {
); );
let node_id = network.node_id_bytes(); let node_id = network.node_id_bytes();
// Seed the posting-identity table from the network key on first launch // Resolve default posting identity (now guaranteed to exist).
// (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 (default_posting_id, default_posting_secret) = {
let s = storage.get().await; let s = storage.get().await;
s.seed_posting_identity_from_network(&node_id, &secret_seed)?; let default_id = s.get_default_posting_id()?
let default_id = s.get_default_posting_id()?.unwrap_or(node_id); .ok_or_else(|| anyhow::anyhow!("default posting identity missing after initialization"))?;
let default_seed = s.get_posting_identity(&default_id)? let pi = s.get_posting_identity(&default_id)?
.map(|pi| pi.secret_seed) .ok_or_else(|| anyhow::anyhow!("default posting identity row missing"))?;
.unwrap_or(secret_seed); (pi.node_id, pi.secret_seed)
(default_id, default_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; 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) // Build the node (fast path — no network I/O beyond endpoint creation)
let activity_log_ref = Arc::clone(&activity_log); let activity_log_ref = Arc::clone(&activity_log);
@ -1041,25 +1080,27 @@ impl Node {
pub async fn get_feed( pub async fn get_feed(
&self, &self,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> { ) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
let (raw, group_seeds) = { let (raw, group_seeds, personas) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let posts = storage.get_feed()?; let posts = storage.get_feed()?;
let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); 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( pub async fn get_all_posts(
&self, &self,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> { ) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
let (raw, group_seeds) = { let (raw, group_seeds, personas) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let posts = storage.list_posts_reverse_chron()?; let posts = storage.list_posts_reverse_chron()?;
let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); 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( pub async fn get_feed_page(
@ -1067,13 +1108,14 @@ impl Node {
before_ms: Option<u64>, before_ms: Option<u64>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> { ) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
let (raw, group_seeds) = { let (raw, group_seeds, personas) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let posts = storage.get_feed_page(before_ms, limit)?; let posts = storage.get_feed_page(before_ms, limit)?;
let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); 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( pub async fn get_all_posts_page(
@ -1081,19 +1123,23 @@ impl Node {
before_ms: Option<u64>, before_ms: Option<u64>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> { ) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
let (raw, group_seeds) = { let (raw, group_seeds, personas) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let posts = storage.list_posts_page(before_ms, limit)?; let posts = storage.list_posts_page(before_ms, limit)?;
let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); 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( fn decrypt_posts(
&self,
posts: Vec<(PostId, Post, PostVisibility)>, posts: Vec<(PostId, Post, PostVisibility)>,
group_seeds: &std::collections::HashMap<(crate::types::GroupId, crate::types::GroupEpoch), ([u8; 32], [u8; 32])>, 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>)> { ) -> Vec<(PostId, Post, PostVisibility, Option<String>)> {
posts posts
.into_iter() .into_iter()
@ -1101,14 +1147,17 @@ impl Node {
let decrypted = match &vis { let decrypted = match &vis {
PostVisibility::Public => None, PostVisibility::Public => None,
PostVisibility::Encrypted { recipients } => { PostVisibility::Encrypted { recipients } => {
personas.iter().find_map(|pi| {
crypto::decrypt_post( crypto::decrypt_post(
&post.content, &post.content,
&self.default_posting_secret, &pi.secret_seed,
&self.node_id, &pi.node_id,
&post.author, &post.author,
recipients, recipients,
) )
.unwrap_or(None) .ok()
.flatten()
})
} }
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
group_seeds.get(&(*group_id, *epoch)) group_seeds.get(&(*group_id, *epoch))
@ -1190,6 +1239,10 @@ impl Node {
.as_millis() as u64; .as_millis() as u64;
let recent_peers = self.current_recent_peers().await; 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 profile = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); 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)) self.set_setting("active_default_posting_id", &hex::encode(node_id))
} }
/// Ensure the posting_identities table has at least one entry. On first pub fn count_posting_identities(&self) -> anyhow::Result<u64> {
/// launch after 0.6.3 upgrade, copies the network key from let n: i64 = self.conn.prepare(
/// `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", "SELECT COUNT(*) FROM posting_identities",
)?.query_row([], |row| row.get(0))?; )?.query_row([], |row| row.get(0))?;
if existing == 0 { Ok(n as u64)
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) --- // --- File holders (flat, per-file, LRU-capped at 5) ---

View file

@ -79,6 +79,22 @@ pub struct PublicProfile {
pub avatar_cid: Option<[u8; 32]>, 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 { fn default_true() -> bool {
true true
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
[lib] [lib]

View file

@ -16,7 +16,10 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.itsgoin_desktop" android:theme="@style/Theme.itsgoin_desktop"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask" android:launchMode="singleTask"

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Disable cloud backup and device-to-device transfer of app data.
The identity secret in identity.key grants full access to all of a user's
private content (DMs, encrypted posts, persona keys). Silently replicating
it to Google Drive / device-transfer without a conscious user action is not
an acceptable default. Users who want backup can use in-app
Settings -> Export, which produces a ZIP the user explicitly handles.
Android 12+ (API 31+) reads this file. Combined with allowBackup="false"
and fullBackupContent="false" in AndroidManifest.xml for older Android.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View file

@ -299,7 +299,9 @@ async fn post_to_dto(
} }
} }
/// Decrypt a just-created post for immediate display. /// 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.
async fn decrypt_just_created( async fn decrypt_just_created(
node: &Node, node: &Node,
post: &Post, post: &Post,
@ -308,11 +310,15 @@ async fn decrypt_just_created(
match vis { match vis {
PostVisibility::Public => None, PostVisibility::Public => None,
PostVisibility::Encrypted { recipients } => { 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( itsgoin_core::crypto::decrypt_post(
&post.content, &post.content,
&node.secret_seed_bytes(), &author_identity.secret_seed,
&node.node_id, &author_identity.node_id,
&node.node_id, &author_identity.node_id,
recipients, recipients,
) )
.ok() .ok()
@ -1946,10 +1952,47 @@ async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<Str
let path = builder.blocking_pick_file(); let path = builder.blocking_pick_file();
Ok(path.map(|p| p.to_string())) Ok(path.map(|p| p.to_string()))
} }
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(target_os = "android")]
{
// Android: SAF "open document" dialog. The dialog returns a content
// URI, not a filesystem path, so we read the bytes via the plugin
// and stage them in the app's private cache so existing import code
// (which expects a path) can read the file normally.
let _ = (title,);
use tauri_plugin_android_fs::AndroidFsExt;
let mime_types: Vec<&str> = 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")]
{ {
let _ = (app, title, filter_name, filter_ext); let _ = (app, title, filter_name, filter_ext);
Ok(None) // Mobile: file picker not supported via this command Ok(None)
} }
} }
@ -2202,7 +2245,14 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
#[tauri::command] #[tauri::command]
async fn reset_data(state: State<'_, AppNode>) -> Result<String, String> { async fn reset_data(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await; let node = get_node(&state).await;
let sentinel = node.data_dir.join(".reset"); // 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");
std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?; std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?;
Ok("Reset scheduled. Restart the app to apply.".to_string()) Ok("Reset scheduled. Restart the app to apply.".to_string())
} }
@ -2730,6 +2780,23 @@ async fn import_public_posts(
Ok(result.message) 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<String, String> {
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] #[tauri::command]
async fn import_as_new_identity( async fn import_as_new_identity(
state: State<'_, AppIdentity>, state: State<'_, AppIdentity>,
@ -2812,12 +2879,24 @@ pub fn run() {
}; };
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
// Check for reset sentinel from previous session // 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.
let sentinel = data_dir.join(".reset"); let sentinel = data_dir.join(".reset");
if sentinel.exists() { if sentinel.exists() {
info!("Reset sentinel found — clearing data"); info!("Reset sentinel found — wiping all app data");
let _ = std::fs::remove_file(data_dir.join("itsgoin.db")); if let Ok(entries) = std::fs::read_dir(&data_dir) {
let _ = std::fs::remove_dir_all(data_dir.join("blobs")); 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);
}
}
}
let _ = std::fs::remove_file(&sentinel); let _ = std::fs::remove_file(&sentinel);
} }
@ -2992,6 +3071,7 @@ pub fn run() {
import_summary, import_summary,
import_public_posts, import_public_posts,
import_as_new_identity, import_as_new_identity,
import_as_personas_cmd,
import_merge_with_key, import_merge_with_key,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())

View file

@ -1,6 +1,6 @@
{ {
"productName": "itsgoin", "productName": "itsgoin",
"version": "0.6.0", "version": "0.6.1",
"identifier": "com.itsgoin.app", "identifier": "com.itsgoin.app",
"build": { "build": {
"frontendDist": "../../frontend", "frontendDist": "../../frontend",

0
deploy.sh Normal file → Executable file
View file

View file

@ -2701,13 +2701,13 @@ async function doSyncAll() {
} }
async function doSetupName() { async function doSetupName() {
// Name is optional — users who want to stay anonymous can proceed with a blank field.
const name = setupName.value.trim(); const name = setupName.value.trim();
if (!name) return;
setupBtn.disabled = true; setupBtn.disabled = true;
try { try {
await invoke('set_display_name', { name }); await invoke('set_display_name', { name });
setupOverlay.classList.add('hidden'); setupOverlay.classList.add('hidden');
toast('Welcome, ' + name + '!'); toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
loadNodeInfo(); loadNodeInfo();
} catch (e) { } catch (e) {
toast('Error: ' + e); toast('Error: ' + e);
@ -3797,9 +3797,10 @@ $('#import-btn').addEventListener('click', () => {
</div> </div>
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div> <div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem"> <div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label> <label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="as_personas" checked /> Restore as personas &mdash; posts keep their original authors; source's keys become personas you can post as</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" checked /> Import public posts into current identity</label> <label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as a separate identity (own data dir)</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="merge_key" /> Merge with decryption key (decrypt + re-create)</label> <label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" /> Public posts only (no keys imported)</label>
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="merge_key" /> Consolidate under current default persona (requires source key)</label>
<div id="merge-key-input" style="display:none;margin-top:0.4rem"> <div id="merge-key-input" style="display:none;margin-top:0.4rem">
<input id="import-merge-key" type="text" placeholder="Original identity key (64 hex chars)" maxlength="64" style="width:100%;font-family:monospace;font-size:0.7rem" /> <input id="import-merge-key" type="text" placeholder="Original identity key (64 hex chars)" maxlength="64" style="width:100%;font-family:monospace;font-size:0.7rem" />
</div> </div>
@ -3866,7 +3867,9 @@ $('#import-btn').addEventListener('click', () => {
overlay.querySelector('#import-go').disabled = true; overlay.querySelector('#import-go').disabled = true;
try { try {
let result; let result;
if (action === 'add_identity') { if (action === 'as_personas') {
result = await invoke('import_as_personas_cmd', { zipPath });
} else if (action === 'add_identity') {
result = await invoke('import_as_new_identity', { zipPath }); result = await invoke('import_as_new_identity', { zipPath });
} else if (action === 'merge_key') { } else if (action === 'merge_key') {
const keyHex = overlay.querySelector('#import-merge-key').value.trim(); const keyHex = overlay.querySelector('#import-merge-key').value.trim();

View file

@ -7,12 +7,12 @@
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<!-- First-run: set display name --> <!-- First-run: set display name (optional — can be left blank) -->
<div id="setup-overlay" class="overlay hidden"> <div id="setup-overlay" class="overlay hidden">
<div class="overlay-box"> <div class="overlay-box">
<h2>Welcome to ItsGoin</h2> <h2>Welcome to ItsGoin</h2>
<p>Choose a display name to get started.</p> <p>Pick a display name if you want one &mdash; or leave blank to stay anonymous.</p>
<input id="setup-name" type="text" placeholder="Your name" maxlength="50" autofocus /> <input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
<button id="setup-btn" class="btn btn-primary">Continue</button> <button id="setup-btn" class="btn btn-primary">Continue</button>
</div> </div>
</div> </div>

View file

@ -46,21 +46,21 @@
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge &mdash; it no longer connects to the live network.</p> <p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge &mdash; it no longer connects to the live network.</p>
</div> </div>
<h2 style="margin-top: 2rem;">v0.6.0 &mdash; April 21, 2026</h2> <h2 style="margin-top: 2rem;">v0.6.1 &mdash; April 22, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">The privacy architecture overhaul. Network identity is decoupled from posting identity &mdash; peers cannot correlate network traffic to a posting identity, and users can hold multiple simultaneous posting identities (personas) on one device.</p> <p style="color: var(--text-muted); font-size: 0.85rem;">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.</p>
<div class="downloads"> <div class="downloads">
<a href="itsgoin-0.6.0.apk" class="download-btn btn-android"> <a href="itsgoin-0.6.1.apk" class="download-btn btn-android">
Android APK Android APK
<span class="sub">v0.6.0</span> <span class="sub">v0.6.1</span>
</a> </a>
<a href="itsgoin_0.6.0_amd64.AppImage" class="download-btn btn-linux"> <a href="itsgoin_0.6.1_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage Linux AppImage
<span class="sub">v0.6.0</span> <span class="sub">v0.6.1</span>
</a> </a>
<a href="itsgoin-cli-0.6.0-linux-amd64" class="download-btn btn-linux"> <a href="itsgoin-cli-0.6.1-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor Linux CLI / Anchor
<span class="sub">v0.6.0</span> <span class="sub">v0.6.1</span>
</a> </a>
</div> </div>
@ -128,6 +128,18 @@
<section> <section>
<h2>Changelog</h2> <h2>Changelog</h2>
<div class="changelog"> <div class="changelog">
<div class="changelog-date">v0.6.1 &mdash; April 22, 2026</div>
<ul>
<li><strong>Network ID and posting ID are now separate by default.</strong> 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.</li>
<li><strong>Android auto-backup disabled.</strong> Previously the app inherited Android's default <code>allowBackup=true</code>, which silently uploaded <code>identity.key</code> (your root secret &mdash; 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 <code>data_extraction_rules</code> for Android 12+ cloud and device-transfer paths. Users who want off-device backup use Settings &rarr; Export (explicit ZIP under their control).</li>
<li><strong>Reset All Data actually resets on Android.</strong> The sentinel file was being written to a subdir the startup check never looked at. Also wipes <code>identity.key</code>, WAL/SHM, and all identity subdirs &mdash; truly fresh on next launch.</li>
<li><strong>Display name is optional on first-run.</strong> Blank field is accepted &mdash; 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.</li>
<li><strong>Import preserves personas.</strong> 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.</li>
<li><strong>Android import Browse button works.</strong> Wired to Storage Access Framework's <code>OPEN_DOCUMENT</code>; picked file is staged in the app's private cache for the existing importer.</li>
<li><strong>Decrypt tries all held personas.</strong> Multi-persona users (or anyone who imported prior identities as personas) can now decrypt DMs addressed to any of their keys.</li>
<li><strong>Profile broadcasts strip persona display data.</strong> Network-keyed profile carries only routing metadata (anchors, recent_peers, preferred_peers). <code>display_name</code> / <code>bio</code> / <code>avatar</code> are no longer sent on the wire. Peers render author names as hex until v0.6.2 adds persona-signed profile posts.</li>
</ul>
<div class="changelog-date">v0.6.0 &mdash; April 21, 2026</div> <div class="changelog-date">v0.6.0 &mdash; April 21, 2026</div>
<ul> <ul>
<li><strong>Network fork from v0.5.</strong> The network protocol has changed enough that v0.5 and v0.6 cannot interoperate. Upgrade if you want to stay reachable.</li> <li><strong>Network fork from v0.5.</strong> The network protocol has changed enough that v0.5 and v0.6 cannot interoperate. Upgrade if you want to stay reachable.</li>