From 97dc83f9f129dab297a59f395aa15f42dd427c52 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Sun, 5 Apr 2026 14:47:24 -0400 Subject: [PATCH] v0.5.0-beta: merge-with-key import, prior_author provenance, beta versioning Merge-with-key: decrypt exported posts using original identity seed, re-create under current identity with prior_author in BlobHeader for provenance tracking. Download page restructured with stable (v0.4.4) + beta (v0.5.0-beta) sections. Version bumped across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 50 +++++++++ crates/cli/Cargo.toml | 2 +- crates/core/Cargo.toml | 2 +- crates/core/src/connection.rs | 1 + crates/core/src/import.rs | 175 +++++++++++++++++++++++++++++++ crates/core/src/node.rs | 9 ++ crates/core/src/types.rs | 3 + crates/tauri-app/Cargo.toml | 2 +- crates/tauri-app/src/lib.rs | 19 ++++ crates/tauri-app/tauri.conf.json | 2 +- frontend/app.js | 16 +++ website/design.html | 2 +- website/download.html | 38 ++++++- 13 files changed, 311 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0368c06..f076bee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -974,6 +983,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -2742,6 +2762,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "zip", ] [[package]] @@ -7537,12 +7558,41 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 58d0f31..b8c9f5a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.3.0" +version = "0.5.0" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index db848ee..43bac21 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.3.0" +version = "0.5.0" edition = "2021" [dependencies] diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 76e751c..84383c5 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6334,6 +6334,7 @@ impl ConnectionManager { thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], + prior_author: None, } }); header.reactions = reactions; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index 0253b83..fc42725 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -241,6 +241,181 @@ pub fn import_as_identity( Ok(manifest.node_id) } +/// Merge posts from another identity into the current one using the original key for decryption. +/// Decrypts encrypted posts, creates new posts under the current identity, preserves timestamps. +/// BlobHeader gets `prior_author` set for provenance. +pub async fn merge_with_key( + zip_path: &Path, + original_key_hex: &str, + storage: &StoragePool, + blob_store: &BlobStore, + our_node_id: &NodeId, + _our_seed: &[u8; 32], +) -> anyhow::Result { + // Derive the original identity from the provided key + let original_seed_bytes = hex::decode(original_key_hex)?; + let original_seed: [u8; 32] = original_seed_bytes.try_into() + .map_err(|_| anyhow::anyhow!("key must be 32 bytes (64 hex chars)"))?; + let original_secret_key = iroh::SecretKey::from_bytes(&original_seed); + let original_node_id: NodeId = *original_secret_key.public().as_bytes(); + + // Phase 1: Read and decrypt everything from ZIP synchronously + let parsed = { + let zip_path = zip_path.to_path_buf(); + let our_nid = *our_node_id; + let orig_seed = original_seed; + let orig_nid = original_node_id; + + tokio::task::spawn_blocking(move || -> anyhow::Result { + let file = std::fs::File::open(&zip_path)?; + let mut archive = zip::ZipArchive::new(file)?; + + let posts: Vec = { + let mut entry = archive.by_name("itsgoin-export/posts.json")?; + let mut buf = String::new(); + entry.read_to_string(&mut buf)?; + serde_json::from_str(&buf)? + }; + + let mut result_posts = Vec::new(); + let mut skipped = 0usize; + + for ep in &posts { + let vis: PostVisibility = serde_json::from_str(&ep.visibility_json) + .unwrap_or(PostVisibility::Public); + let attachments: Vec = serde_json::from_str(&ep.attachments_json) + .unwrap_or_default(); + + // Decrypt content if encrypted + let plaintext = match &vis { + PostVisibility::Public => ep.content.clone(), + PostVisibility::Encrypted { recipients } => { + match crate::crypto::decrypt_post( + &ep.content, &orig_seed, &orig_nid, &orig_nid, recipients, + ) { + Ok(Some(text)) => text, + Ok(None) => { + debug!(post = ep.id, "Not a recipient of this post — skipping"); + skipped += 1; + continue; + } + Err(e) => { + warn!(post = ep.id, error = %e, "Failed to decrypt post — skipping"); + skipped += 1; + continue; + } + } + } + PostVisibility::GroupEncrypted { .. } => { + // Group decryption needs the group seed — skip for now + debug!(post = ep.id, "Group-encrypted post — skipping (group merge not yet supported)"); + skipped += 1; + continue; + } + }; + + // Create new post under our identity + let new_post = Post { + author: our_nid, + content: plaintext, + attachments: attachments.clone(), + timestamp_ms: ep.timestamp_ms, + }; + + // Read blob data from archive (may need decryption for encrypted posts) + let mut blob_data = Vec::new(); + for att in &attachments { + let cid_hex = hex::encode(att.cid); + let blob_path = format!("itsgoin-export/blobs/{}", cid_hex); + if let Ok(mut blob_entry) = archive.by_name(&blob_path) { + let mut data = Vec::new(); + blob_entry.read_to_end(&mut data)?; + + // If the post was encrypted, blobs are also encrypted with the same CEK + if matches!(vis, PostVisibility::Encrypted { .. }) { + if let PostVisibility::Encrypted { ref recipients } = vis { + if let Ok(Some(cek)) = crate::crypto::unwrap_cek_for_recipient( + &orig_seed, &orig_nid, &orig_nid, recipients, + ) { + if let Ok(decrypted) = crate::crypto::decrypt_bytes_with_cek(&data, &cek) { + data = decrypted; + } + } + } + } + + blob_data.push((att.clone(), data)); + } + } + + // Merged posts go in as public (decrypted content, new author) + result_posts.push((new_post, PostVisibility::Public, blob_data)); + } + + Ok(ParsedImport { posts: result_posts, skipped }) + }).await?? + }; + + // Phase 2: Store with prior_author provenance + let mut imported = 0usize; + let mut blobs_imported = 0usize; + + for (new_post, _vis, blob_data) in &parsed.posts { + let new_id = compute_post_id(new_post); + + let s = storage.get().await; + if s.get_post(&new_id).ok().flatten().is_some() { + continue; + } + s.store_post_with_visibility(&new_id, new_post, &PostVisibility::Public)?; + + // Create BlobHeader with prior_author + let now = now_ms(); + let header = crate::types::BlobHeader { + post_id: new_id, + author: *our_node_id, + reactions: vec![], + comments: vec![], + policy: crate::types::CommentPolicy::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: Some(original_node_id), + }; + let header_json = serde_json::to_string(&header).unwrap_or_default(); + let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now); + drop(s); + + for (att, data) in blob_data { + if !blob_store.has(&att.cid) { + blob_store.store(&att.cid, data)?; + let s = storage.get().await; + let _ = s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes); + blobs_imported += 1; + } + } + + imported += 1; + } + + info!( + imported, skipped = parsed.skipped, blobs = blobs_imported, + original = hex::encode(original_node_id), + "Merge with key complete" + ); + + Ok(ImportResult { + posts_imported: imported, + posts_skipped: parsed.skipped, + blobs_imported, + message: format!( + "Merged {} posts from {} ({} skipped), {} blobs", + imported, &hex::encode(original_node_id)[..12], parsed.skipped, blobs_imported + ), + }) +} + fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 3eba7af..9ae8cfa 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -533,6 +533,10 @@ impl Node { // ---- Identity export/import ---- + pub fn secret_seed(&self) -> [u8; 32] { + self.secret_seed + } + pub fn export_identity_hex(&self) -> anyhow::Result { let key_path = self.data_dir.join("identity.key"); let key_bytes = std::fs::read(&key_path)?; @@ -753,6 +757,7 @@ impl Node { thread_splits: vec![], receipt_slots, comment_slots, + prior_author: None, }; let header_json = serde_json::to_string(&blob_header)?; storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?; @@ -3912,6 +3917,7 @@ impl Node { thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], + prior_author: None, }) } else { crate::types::BlobHeader { @@ -3924,6 +3930,7 @@ impl Node { thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], + prior_author: None, } }; @@ -4001,6 +4008,7 @@ impl Node { thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], + prior_author: None, }) } else { crate::types::BlobHeader { @@ -4013,6 +4021,7 @@ impl Node { thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], + prior_author: None, } }; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index ddc7611..662cd73 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -859,6 +859,9 @@ pub struct BlobHeader { /// Encrypted comment slots (each 256 bytes) — only for encrypted posts #[serde(default)] pub comment_slots: Vec>, + /// Original author NodeId before post merge (set during cross-identity import) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_author: Option, } /// Receipt slot state byte values diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index eb4fe09..bdd6a76 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.4.4" +version = "0.5.0" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 5d1c2c7..443fbcd 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -2216,6 +2216,24 @@ async fn import_as_new_identity( Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12])) } +#[tauri::command] +async fn import_merge_with_key( + state: State<'_, AppNode>, + zip_path: String, + key_hex: String, +) -> Result { + let node = get_node(&state).await; + let result = itsgoin_core::import::merge_with_key( + std::path::Path::new(&zip_path), + &key_hex, + &node.storage, + &node.blob_store, + &node.node_id, + &node.secret_seed(), + ).await.map_err(|e| e.to_string())?; + Ok(result.message) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() @@ -2408,6 +2426,7 @@ pub fn run() { import_summary, import_public_posts, import_as_new_identity, + import_merge_with_key, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 444d033..b46c8a2 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.4.4", + "version": "0.5.0", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 34457dd..62f8461 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3379,6 +3379,10 @@ $('#import-btn').addEventListener('click', () => {
@@ -3418,6 +3422,14 @@ $('#import-btn').addEventListener('click', () => { } }); + // Show/hide merge key input based on selected action + overlay.querySelectorAll('input[name="import-action"]').forEach(radio => { + radio.addEventListener('change', () => { + const mergeInput = overlay.querySelector('#merge-key-input'); + mergeInput.style.display = radio.value === 'merge_key' && radio.checked ? 'block' : 'none'; + }); + }); + overlay.querySelector('#import-go').addEventListener('click', async () => { const zipPath = overlay.querySelector('#import-zip-path').value.trim(); const action = overlay.querySelector('input[name="import-action"]:checked')?.value; @@ -3429,6 +3441,10 @@ $('#import-btn').addEventListener('click', () => { let result; if (action === 'add_identity') { result = await invoke('import_as_new_identity', { zipPath }); + } else if (action === 'merge_key') { + const keyHex = overlay.querySelector('#import-merge-key').value.trim(); + if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); overlay.querySelector('#import-go').disabled = false; return; } + result = await invoke('import_merge_with_key', { zipPath, keyHex }); } else { result = await invoke('import_public_posts', { zipPath }); } diff --git a/website/design.html b/website/design.html index c9ccfdc..27d164d 100644 --- a/website/design.html +++ b/website/design.html @@ -39,7 +39,7 @@
- v0.4.4 — 2026-03-31 + v0.5.0-beta — 2026-04-05

Design Document

This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.

diff --git a/website/download.html b/website/download.html index 7c629e9..3db4e6e 100644 --- a/website/download.html +++ b/website/download.html @@ -25,16 +25,33 @@

Download ItsGoin

Available for Android and Linux. Free and open source.

+ +

Stable Release

Version 0.4.4 — March 23, 2026

+ +

Beta Release

+

Version 0.5.0-beta — April 5, 2026

+

Multi-identity, export/import, post merge with decryption key. May contain bugs — stable release recommended for daily use.

+ +
@@ -46,7 +63,7 @@

Android

  1. Download the APK — Tap the button above. Your browser may warn that this type of file can be harmful — tap Download anyway.
  2. -
  3. Open the file — When the download finishes, tap the notification or find itsgoin-0.4.4.apk in your Downloads folder and tap it.
  4. +
  5. Open the file — When the download finishes, tap the notification or find the APK in your Downloads folder and tap it.
  6. Allow installation — Android will ask you to allow installs from this source. Tap Settings, toggle "Allow from this source", then go back and tap Install.
  7. Launch the app — Once installed, tap Open or find ItsGoin in your app drawer.
@@ -59,8 +76,8 @@

Linux (AppImage)

  1. Download the AppImage — Click the button above to download.
  2. -
  3. Make it executable — Open a terminal and run:
    chmod +x itsgoin_0.4.4_amd64.AppImage
  4. -
  5. Run it — Double-click the file, or from the terminal:
    ./itsgoin_0.4.4_amd64.AppImage
  6. +
  7. Make it executable — Open a terminal and run:
    chmod +x itsgoin_*.AppImage
  8. +
  9. Run it — Double-click the file, or from the terminal:
    ./itsgoin_*.AppImage
Note: If it doesn't launch, you may need to install FUSE:
sudo apt install libfuse2 (Debian/Ubuntu) or sudo dnf install fuse (Fedora). @@ -71,6 +88,17 @@

Changelog

+
v0.5.0-beta — April 5, 2026
+
    +
  • Multi-identity — Create, switch, and delete multiple identities on one device. Per-identity data directories with hot-swap switching (~3s). Legacy flat layout auto-migrates on first launch.
  • +
  • ZIP export — Export your data as a ZIP with scope selection: identity only (key backup), posts only (safe to share), posts + identity (full migration), or everything (complete backup including follows and settings). Auto-chunks at 4GB.
  • +
  • Public post import — Import public posts from another identity's export ZIP into your current identity. Creates new PostIds under your author, preserving original timestamps.
  • +
  • Merge with decryption key — Import encrypted posts from another identity by providing the original identity key. Decrypts posts and blobs, re-creates them under your current identity. BlobHeader tracks prior_author for provenance.
  • +
  • Import as new identity — Import a ZIP containing an identity key as a new identity. Creates the identity subdir; switch to it to access the data.
  • +
  • Hole punch address filtering — Relay introductions now filter by address family (IPv4/IPv6) and exclude LAN-only addresses for remote peers.
  • +
  • Sync pipeline fixes — Per-peer sync resets last_sync_ms before pulling (fixes stale sync). ManifestPush now fetches blobs after discovering new posts.
  • +
+
v0.4.4 — March 23, 2026
  • UI overhaul — Sticky header with tabs as one floating block on desktop. Fixed header + bottom nav on mobile. Full-width dark header with 15px fade gradient into content.