Compare commits

..

No commits in common. "12e0d4eccc6700c4bec26d9386544b9b33098c57" and "b789ab5a19e6db1dd63efcf28e636c1550ef8b3a" have entirely different histories.

18 changed files with 108 additions and 526 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.6.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hex", "hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.6.1" version = "0.6.0"
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.1" version = "0.6.0"
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.1" version = "0.6.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]

View file

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

View file

@ -1513,11 +1513,7 @@ 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()?;
// Profile keyed by network id (used for N1/N2/N3 routing). let profile = storage.get_profile(&self.our_node_id)?;
// 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)?;
@ -1660,11 +1656,7 @@ 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()?;
// Profile keyed by network id (used for N1/N2/N3 routing). let profile = storage.get_profile(&self.our_node_id)?;
// 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)?;
@ -8399,9 +8391,7 @@ 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()?;
// Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)?;
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)?;
@ -8480,9 +8470,7 @@ 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()?;
// Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)?;
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,235 +119,6 @@ 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,13 +904,12 @@ 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 {
// v0.6.1: profiles broadcast on the wire are keyed by the network // Sanitize: if public_visible=false, strip display_name/bio from pushed profile
// NodeId. They carry ONLY routing metadata (anchors, recent_peers, let mut push_profile = profile.clone();
// preferred_peers) — no display name / bio / avatar. Attaching a if !profile.public_visible {
// human-readable name to the network id would correlate the QUIC push_profile.display_name = String::new();
// endpoint to a specific person. Persona-level display data will push_profile.bio = String::new();
// 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,10 +73,9 @@ 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 (network secret — QUIC endpoint only, // Load or generate identity key
// 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 (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 key_bytes = std::fs::read(&key_path)?;
let bytes: [u8; 32] = key_bytes let bytes: [u8; 32] = key_bytes
.try_into() .try_into()
@ -86,7 +85,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 network identity key"); info!("Generated new identity key");
(key, seed) (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 // Open blob store
let blob_store = Arc::new(BlobStore::open(&data_dir)?); let blob_store = Arc::new(BlobStore::open(&data_dir)?);
@ -162,22 +117,28 @@ impl Node {
); );
let node_id = network.node_id_bytes(); 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 (default_posting_id, default_posting_secret) = {
let s = storage.get().await; let s = storage.get().await;
let default_id = s.get_default_posting_id()? s.seed_posting_identity_from_network(&node_id, &secret_seed)?;
.ok_or_else(|| anyhow::anyhow!("default posting identity missing after initialization"))?; let default_id = s.get_default_posting_id()?.unwrap_or(node_id);
let pi = s.get_posting_identity(&default_id)? let default_seed = s.get_posting_identity(&default_id)?
.ok_or_else(|| anyhow::anyhow!("default posting identity row missing"))?; .map(|pi| pi.secret_seed)
(pi.node_id, pi.secret_seed) .unwrap_or(secret_seed);
(default_id, default_seed)
}; };
// Auto-follow our default posting identity so our own posts show in // Auto-follow ourselves so our own posts show in the feed
// 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);
@ -1080,27 +1041,25 @@ 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, personas) = { let (raw, group_seeds) = {
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();
let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds)
(posts, seeds, personas)
}; };
Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) Ok(self.decrypt_posts(raw, &group_seeds))
} }
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, personas) = { let (raw, group_seeds) = {
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();
let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds)
(posts, seeds, personas)
}; };
Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) Ok(self.decrypt_posts(raw, &group_seeds))
} }
pub async fn get_feed_page( pub async fn get_feed_page(
@ -1108,14 +1067,13 @@ 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, personas) = { let (raw, group_seeds) = {
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();
let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds)
(posts, seeds, personas)
}; };
Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) Ok(self.decrypt_posts(raw, &group_seeds))
} }
pub async fn get_all_posts_page( pub async fn get_all_posts_page(
@ -1123,23 +1081,19 @@ 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, personas) = { let (raw, group_seeds) = {
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();
let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds)
(posts, seeds, personas)
}; };
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( 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()
@ -1147,17 +1101,14 @@ 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,
&pi.secret_seed, &self.default_posting_secret,
&pi.node_id, &self.node_id,
&post.author, &post.author,
recipients, recipients,
) )
.ok() .unwrap_or(None)
.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))
@ -1239,10 +1190,6 @@ 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,11 +4353,31 @@ impl Storage {
self.set_setting("active_default_posting_id", &hex::encode(node_id)) self.set_setting("active_default_posting_id", &hex::encode(node_id))
} }
pub fn count_posting_identities(&self) -> anyhow::Result<u64> { /// Ensure the posting_identities table has at least one entry. On first
let n: i64 = self.conn.prepare( /// 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", "SELECT COUNT(*) FROM posting_identities",
)?.query_row([], |row| row.get(0))?; )?.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) --- // --- File holders (flat, per-file, LRU-capped at 5) ---

View file

@ -79,22 +79,6 @@ 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.1" version = "0.6.0"
edition = "2021" edition = "2021"
[lib] [lib]

View file

@ -16,10 +16,7 @@
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

@ -1,29 +0,0 @@
<?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,9 +299,7 @@ async fn post_to_dto(
} }
} }
/// Decrypt a just-created post for immediate display. The post was authored /// Decrypt a just-created post for immediate display.
/// 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,
@ -310,15 +308,11 @@ 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,
&author_identity.secret_seed, &node.secret_seed_bytes(),
&author_identity.node_id, &node.node_id,
&author_identity.node_id, &node.node_id,
recipients, recipients,
) )
.ok() .ok()
@ -1952,47 +1946,10 @@ 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(target_os = "android")] #[cfg(any(target_os = "android", target_os = "ios"))]
{
// 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) Ok(None) // Mobile: file picker not supported via this command
} }
} }
@ -2245,14 +2202,7 @@ 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;
// Write the sentinel at the APP-level data_dir (parent of the active let sentinel = node.data_dir.join(".reset");
// 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())
} }
@ -2780,23 +2730,6 @@ 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>,
@ -2879,24 +2812,12 @@ pub fn run() {
}; };
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
// Check for reset sentinel from previous session. A "Reset All // Check for reset sentinel from previous session
// 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 — wiping all app data"); info!("Reset sentinel found — clearing data");
if let Ok(entries) = std::fs::read_dir(&data_dir) { let _ = std::fs::remove_file(data_dir.join("itsgoin.db"));
for entry in entries.flatten() { let _ = std::fs::remove_dir_all(data_dir.join("blobs"));
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);
} }
@ -3071,7 +2992,6 @@ 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.1", "version": "0.6.0",
"identifier": "com.itsgoin.app", "identifier": "com.itsgoin.app",
"build": { "build": {
"frontendDist": "../../frontend", "frontendDist": "../../frontend",

0
deploy.sh Executable file → Normal 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(name ? 'Welcome, ' + name + '!' : 'Welcome!'); toast('Welcome, ' + name + '!');
loadNodeInfo(); loadNodeInfo();
} catch (e) { } catch (e) {
toast('Error: ' + e); toast('Error: ' + e);
@ -3797,10 +3797,9 @@ $('#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="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="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="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="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="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" /> Merge with decryption key (decrypt + re-create)</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>
@ -3867,9 +3866,7 @@ $('#import-btn').addEventListener('click', () => {
overlay.querySelector('#import-go').disabled = true; overlay.querySelector('#import-go').disabled = true;
try { try {
let result; let result;
if (action === 'as_personas') { if (action === 'add_identity') {
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 (optional — can be left blank) --> <!-- First-run: set display name -->
<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>Pick a display name if you want one &mdash; or leave blank to stay anonymous.</p> <p>Choose a display name to get started.</p>
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus /> <input id="setup-name" type="text" placeholder="Your name" 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.1 &mdash; April 22, 2026</h2> <h2 style="margin-top: 2rem;">v0.6.0 &mdash; April 21, 2026</h2>
<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> <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>
<div class="downloads"> <div class="downloads">
<a href="itsgoin-0.6.1.apk" class="download-btn btn-android"> <a href="itsgoin-0.6.0.apk" class="download-btn btn-android">
Android APK Android APK
<span class="sub">v0.6.1</span> <span class="sub">v0.6.0</span>
</a> </a>
<a href="itsgoin_0.6.1_amd64.AppImage" class="download-btn btn-linux"> <a href="itsgoin_0.6.0_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage Linux AppImage
<span class="sub">v0.6.1</span> <span class="sub">v0.6.0</span>
</a> </a>
<a href="itsgoin-cli-0.6.1-linux-amd64" class="download-btn btn-linux"> <a href="itsgoin-cli-0.6.0-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor Linux CLI / Anchor
<span class="sub">v0.6.1</span> <span class="sub">v0.6.0</span>
</a> </a>
</div> </div>
@ -128,18 +128,6 @@
<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>