diff --git a/Cargo.lock b/Cargo.lock index ea15279..d3cf5c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2746,7 +2746,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 573d206..7b4c940 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -37,40 +37,55 @@ fn derive_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] { blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret) } -/// Encrypt a post's plaintext content for the given recipients. -/// -/// Returns `(base64_ciphertext, Vec)` where: -/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content -/// - Each WrappedKey contains the CEK encrypted for one recipient -/// -/// The author (our_seed's corresponding NodeId) is always included as a recipient. -pub fn encrypt_post( - plaintext: &str, +// --- Crypto primitives --- + +/// Generate a random 32-byte Content Encryption Key (CEK). +fn random_cek() -> [u8; 32] { + let mut cek = [0u8; 32]; + rand::rng().fill_bytes(&mut cek); + cek +} + +/// Encrypt arbitrary bytes with a CEK using ChaCha20-Poly1305. +/// Returns `nonce(12) || ciphertext || tag(16)`. +pub fn encrypt_bytes_with_cek(bytes: &[u8], cek: &[u8; 32]) -> Result> { + let cipher = ChaCha20Poly1305::new_from_slice(cek) + .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, bytes) + .map_err(|e| anyhow::anyhow!("encrypt: {}", e))?; + let mut payload = Vec::with_capacity(12 + ciphertext.len()); + payload.extend_from_slice(&nonce_bytes); + payload.extend_from_slice(&ciphertext); + Ok(payload) +} + +/// Decrypt bytes that were encrypted with `encrypt_bytes_with_cek`. +/// Expects `nonce(12) || ciphertext || tag(16)`. +pub fn decrypt_bytes_with_cek(payload: &[u8], cek: &[u8; 32]) -> Result> { + if payload.len() < 12 + 16 { + bail!("encrypted payload too short"); + } + let nonce = Nonce::from_slice(&payload[..12]); + let cipher = ChaCha20Poly1305::new_from_slice(cek) + .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; + let plaintext = cipher + .decrypt(nonce, &payload[12..]) + .map_err(|e| anyhow::anyhow!("decrypt: {}", e))?; + Ok(plaintext) +} + +/// Wrap a CEK for a set of recipients using X25519 DH. +/// The author (our_node_id) is always included. +fn wrap_cek_for_recipients( + cek: &[u8; 32], our_seed: &[u8; 32], our_node_id: &NodeId, recipients: &[NodeId], -) -> Result<(String, Vec)> { - // Generate random 32-byte Content Encryption Key - let mut cek = [0u8; 32]; - rand::rng().fill_bytes(&mut cek); - - // Encrypt content with CEK using ChaCha20-Poly1305 - let content_cipher = ChaCha20Poly1305::new_from_slice(&cek) - .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; - let mut content_nonce_bytes = [0u8; 12]; - rand::rng().fill_bytes(&mut content_nonce_bytes); - let content_nonce = Nonce::from_slice(&content_nonce_bytes); - let ciphertext = content_cipher - .encrypt(content_nonce, plaintext.as_bytes()) - .map_err(|e| anyhow::anyhow!("encrypt: {}", e))?; - - // base64(nonce || ciphertext_with_tag) - let mut payload = Vec::with_capacity(12 + ciphertext.len()); - payload.extend_from_slice(&content_nonce_bytes); - payload.extend_from_slice(&ciphertext); - let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); - - // Get our X25519 private key +) -> Result> { let our_x25519_private = ed25519_seed_to_x25519_private(our_seed); // Build recipient set (always include ourselves) @@ -79,7 +94,6 @@ pub fn encrypt_post( all_recipients.push(*our_node_id); } - // Wrap CEK for each recipient let mut wrapped_keys = Vec::with_capacity(all_recipients.len()); for recipient in &all_recipients { let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?; @@ -106,20 +120,17 @@ pub fn encrypt_post( }); } - Ok((encoded, wrapped_keys)) + Ok(wrapped_keys) } -/// Decrypt a post's content if we are among the recipients. -/// -/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient. -pub fn decrypt_post( - encrypted_content_b64: &str, +/// Unwrap a CEK from wrapped keys if we are a recipient. +/// Returns `Ok(Some(cek))` if we can unwrap, `Ok(None)` if we're not a recipient. +pub fn unwrap_cek_for_recipient( our_seed: &[u8; 32], our_node_id: &NodeId, sender_pubkey: &NodeId, wrapped_keys: &[WrappedKey], -) -> Result> { - // Find our wrapped key +) -> Result> { let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) { Some(wk) => wk, None => return Ok(None), @@ -132,40 +143,142 @@ pub fn decrypt_post( ); } - // DH with sender to get wrapping key let our_x25519_private = ed25519_seed_to_x25519_private(our_seed); let sender_x25519_pub = ed25519_pubkey_to_x25519_public(sender_pubkey)?; let shared_secret = x25519_dh(&our_x25519_private, &sender_x25519_pub); let wrapping_key = derive_wrapping_key(&shared_secret); - // Unwrap CEK let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]); let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; - let cek = wrap_cipher + let cek_vec = wrap_cipher .decrypt(wrap_nonce, &our_wk.wrapped_cek[12..]) .map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?; - if cek.len() != 32 { - bail!("unwrapped CEK wrong length: {}", cek.len()); + if cek_vec.len() != 32 { + bail!("unwrapped CEK wrong length: {}", cek_vec.len()); } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_vec); + Ok(Some(cek)) +} + +/// Unwrap a group-encrypted CEK using the group seed and public key. +pub fn unwrap_group_cek( + group_seed: &[u8; 32], + group_public_key: &[u8; 32], + wrapped_cek: &[u8], +) -> Result<[u8; 32]> { + if wrapped_cek.len() != 60 { + bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len()); + } + + let group_x25519_private = ed25519_seed_to_x25519_private(group_seed); + let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?; + let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public); + let wrapping_key = derive_group_cek_wrapping_key(&shared_secret); + + let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) + .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; + let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]); + let cek_vec = wrap_cipher + .decrypt(wrap_nonce, &wrapped_cek[12..]) + .map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?; + + if cek_vec.len() != 32 { + bail!("unwrapped CEK wrong length: {}", cek_vec.len()); + } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_vec); + Ok(cek) +} + +/// Encrypt a post with a provided CEK, wrapping for recipients. +/// Returns `(base64_ciphertext, Vec)`. +pub fn encrypt_post_with_cek( + plaintext: &str, + cek: &[u8; 32], + our_seed: &[u8; 32], + our_node_id: &NodeId, + recipients: &[NodeId], +) -> Result<(String, Vec)> { + let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?; + let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); + let wrapped_keys = wrap_cek_for_recipients(cek, our_seed, our_node_id, recipients)?; + Ok((encoded, wrapped_keys)) +} + +/// Encrypt a post for a group with a provided CEK. +/// Returns `(base64_ciphertext, wrapped_cek_bytes)`. +pub fn encrypt_post_for_group_with_cek( + plaintext: &str, + cek: &[u8; 32], + group_seed: &[u8; 32], + group_public_key: &[u8; 32], +) -> Result<(String, Vec)> { + let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?; + let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); + + // Wrap CEK using group DH + let group_x25519_private = ed25519_seed_to_x25519_private(group_seed); + let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?; + let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public); + let wrapping_key = derive_group_cek_wrapping_key(&shared_secret); + + let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) + .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; + let mut wrap_nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut wrap_nonce_bytes); + let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes); + let wrapped = wrap_cipher + .encrypt(wrap_nonce, cek.as_slice()) + .map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?; + + let mut wrapped_cek = Vec::with_capacity(60); + wrapped_cek.extend_from_slice(&wrap_nonce_bytes); + wrapped_cek.extend_from_slice(&wrapped); + + Ok((encoded, wrapped_cek)) +} + +/// Encrypt a post's plaintext content for the given recipients. +/// +/// Returns `(base64_ciphertext, Vec)` where: +/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content +/// - Each WrappedKey contains the CEK encrypted for one recipient +/// +/// The author (our_seed's corresponding NodeId) is always included as a recipient. +pub fn encrypt_post( + plaintext: &str, + our_seed: &[u8; 32], + our_node_id: &NodeId, + recipients: &[NodeId], +) -> Result<(String, Vec)> { + let cek = random_cek(); + encrypt_post_with_cek(plaintext, &cek, our_seed, our_node_id, recipients) +} + +/// Decrypt a post's content if we are among the recipients. +/// +/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient. +pub fn decrypt_post( + encrypted_content_b64: &str, + our_seed: &[u8; 32], + our_node_id: &NodeId, + sender_pubkey: &NodeId, + wrapped_keys: &[WrappedKey], +) -> Result> { + let cek = match unwrap_cek_for_recipient(our_seed, our_node_id, sender_pubkey, wrapped_keys)? { + Some(cek) => cek, + None => return Ok(None), + }; // Decode base64 content let payload = base64::engine::general_purpose::STANDARD .decode(encrypted_content_b64) .map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?; - if payload.len() < 12 + 16 { - bail!("encrypted payload too short"); - } - - // Decrypt content - let content_nonce = Nonce::from_slice(&payload[..12]); - let content_cipher = ChaCha20Poly1305::new_from_slice(&cek) - .map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?; - let plaintext = content_cipher - .decrypt(content_nonce, &payload[12..]) - .map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?; + let plaintext = decrypt_bytes_with_cek(&payload, &cek)?; Ok(Some(String::from_utf8(plaintext)?)) } @@ -202,63 +315,12 @@ pub fn rewrap_visibility( existing_recipients: &[WrappedKey], new_recipient_ids: &[NodeId], ) -> Result> { - // Find our wrapped key - let our_wk = existing_recipients - .iter() - .find(|wk| &wk.recipient == our_node_id) + // Unwrap CEK using DH with ourselves (we are both sender and recipient here) + let cek = unwrap_cek_for_recipient(our_seed, our_node_id, our_node_id, existing_recipients)? .ok_or_else(|| anyhow::anyhow!("we are not a recipient of this post"))?; - if our_wk.wrapped_cek.len() != 60 { - bail!( - "invalid wrapped_cek length: expected 60, got {}", - our_wk.wrapped_cek.len() - ); - } - - // DH with ourselves (author DH with self) to unwrap CEK - let our_x25519_private = ed25519_seed_to_x25519_private(our_seed); - let our_x25519_pub = ed25519_pubkey_to_x25519_public(our_node_id)?; - let shared_secret = x25519_dh(&our_x25519_private, &our_x25519_pub); - let wrapping_key = derive_wrapping_key(&shared_secret); - - let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]); - let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) - .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; - let cek = wrap_cipher - .decrypt(wrap_nonce, &our_wk.wrapped_cek[12..]) - .map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?; - - if cek.len() != 32 { - bail!("unwrapped CEK wrong length: {}", cek.len()); - } - - // Re-wrap for each new recipient - let mut wrapped_keys = Vec::with_capacity(new_recipient_ids.len()); - for recipient in new_recipient_ids { - let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?; - let shared_secret = x25519_dh(&our_x25519_private, &their_x25519_pub); - let wrapping_key = derive_wrapping_key(&shared_secret); - - let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) - .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; - let mut wrap_nonce_bytes = [0u8; 12]; - rand::rng().fill_bytes(&mut wrap_nonce_bytes); - let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes); - let wrapped = wrap_cipher - .encrypt(wrap_nonce, cek.as_slice()) - .map_err(|e| anyhow::anyhow!("wrap: {}", e))?; - - let mut wrapped_cek = Vec::with_capacity(60); - wrapped_cek.extend_from_slice(&wrap_nonce_bytes); - wrapped_cek.extend_from_slice(&wrapped); - - wrapped_keys.push(WrappedKey { - recipient: *recipient, - wrapped_cek, - }); - } - - Ok(wrapped_keys) + // Re-wrap for each new recipient (don't auto-add ourselves — caller controls the list) + wrap_cek_for_recipients(&cek, our_seed, our_node_id, new_recipient_ids) } // --- Group Key Encryption --- @@ -352,45 +414,8 @@ pub fn encrypt_post_for_group( group_seed: &[u8; 32], group_public_key: &[u8; 32], ) -> Result<(String, Vec)> { - // Generate random CEK - let mut cek = [0u8; 32]; - rand::rng().fill_bytes(&mut cek); - - // Encrypt content with CEK - let content_cipher = ChaCha20Poly1305::new_from_slice(&cek) - .map_err(|e| anyhow::anyhow!("cipher init: {}", e))?; - let mut content_nonce_bytes = [0u8; 12]; - rand::rng().fill_bytes(&mut content_nonce_bytes); - let content_nonce = Nonce::from_slice(&content_nonce_bytes); - let ciphertext = content_cipher - .encrypt(content_nonce, plaintext.as_bytes()) - .map_err(|e| anyhow::anyhow!("encrypt: {}", e))?; - - let mut payload = Vec::with_capacity(12 + ciphertext.len()); - payload.extend_from_slice(&content_nonce_bytes); - payload.extend_from_slice(&ciphertext); - let encoded = base64::engine::general_purpose::STANDARD.encode(&payload); - - // Wrap CEK using group DH: group_seed (as X25519 private) × group_public_key (as X25519 public) - let group_x25519_private = ed25519_seed_to_x25519_private(group_seed); - let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?; - let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public); - let wrapping_key = derive_group_cek_wrapping_key(&shared_secret); - - let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) - .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; - let mut wrap_nonce_bytes = [0u8; 12]; - rand::rng().fill_bytes(&mut wrap_nonce_bytes); - let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes); - let wrapped = wrap_cipher - .encrypt(wrap_nonce, cek.as_slice()) - .map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?; - - let mut wrapped_cek = Vec::with_capacity(60); - wrapped_cek.extend_from_slice(&wrap_nonce_bytes); - wrapped_cek.extend_from_slice(&wrapped); - - Ok((encoded, wrapped_cek)) + let cek = random_cek(); + encrypt_post_for_group_with_cek(plaintext, &cek, group_seed, group_public_key) } /// Decrypt a group-encrypted post using the group seed and public key. @@ -400,42 +425,14 @@ pub fn decrypt_group_post( group_public_key: &[u8; 32], wrapped_cek: &[u8], ) -> Result { - if wrapped_cek.len() != 60 { - bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len()); - } - - // Unwrap CEK using group DH - let group_x25519_private = ed25519_seed_to_x25519_private(group_seed); - let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?; - let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public); - let wrapping_key = derive_group_cek_wrapping_key(&shared_secret); - - let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) - .map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?; - let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]); - let cek = wrap_cipher - .decrypt(wrap_nonce, &wrapped_cek[12..]) - .map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?; - - if cek.len() != 32 { - bail!("unwrapped CEK wrong length: {}", cek.len()); - } + let cek = unwrap_group_cek(group_seed, group_public_key, wrapped_cek)?; // Decode and decrypt content let payload = base64::engine::general_purpose::STANDARD .decode(encrypted_b64) .map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?; - if payload.len() < 12 + 16 { - bail!("encrypted payload too short"); - } - - let content_nonce = Nonce::from_slice(&payload[..12]); - let content_cipher = ChaCha20Poly1305::new_from_slice(&cek) - .map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?; - let plaintext = content_cipher - .decrypt(content_nonce, &payload[12..]) - .map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?; + let plaintext = decrypt_bytes_with_cek(&payload, &cek)?; Ok(String::from_utf8(plaintext)?) } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 8560f68..cf96371 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -522,24 +522,20 @@ impl Node { } } - // Store blob files and build attachment metadata (DB records deferred until post_id known) - let mut attachments = Vec::with_capacity(attachment_data.len()); - for (data, mime) in &attachment_data { - let cid = crate::blob::compute_blob_id(data); - self.blob_store.store(&cid, data)?; - attachments.push(Attachment { - cid, - mime_type: mime.clone(), - size_bytes: data.len() as u64, - }); - } - let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let (final_content, visibility) = match &intent { - VisibilityIntent::Public => (content, PostVisibility::Public), + // Determine encryption parameters and generate CEK if needed. + // The CEK is generated BEFORE both content and blob encryption so they share the same key. + enum EncryptionMode { + Public, + Recipient { cek: [u8; 32], recipients: Vec }, + Group { cek: [u8; 32], group_id: [u8; 32], epoch: u64, group_seed: [u8; 32], group_pubkey: [u8; 32] }, + } + + let mode = match &intent { + VisibilityIntent::Public => EncryptionMode::Public, VisibilityIntent::Circle(circle_name) => { // Try group encryption first let group_info = { @@ -551,30 +547,17 @@ impl Node { }) }; if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info { - let (encrypted, wrapped_cek) = - crypto::encrypt_post_for_group(&content, &group_seed, &group_pubkey)?; - ( - encrypted, - PostVisibility::GroupEncrypted { - group_id, - epoch, - wrapped_cek, - }, - ) + let mut cek = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); + EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } } else { - // Fallback to per-recipient encryption let recipients = self.resolve_recipients(&intent).await?; if recipients.is_empty() { anyhow::bail!("no recipients resolved for this visibility"); } - let (encrypted, wrapped_keys) = - crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?; - ( - encrypted, - PostVisibility::Encrypted { - recipients: wrapped_keys, - }, - ) + let mut cek = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); + EncryptionMode::Recipient { cek, recipients } } } _ => { @@ -582,8 +565,41 @@ impl Node { if recipients.is_empty() { anyhow::bail!("no recipients resolved for this visibility"); } + let mut cek = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); + EncryptionMode::Recipient { cek, recipients } + } + }; + + // Store blob files — for encrypted posts, encrypt each blob with the shared CEK. + // CID is computed on the ciphertext so peers can verify what they store. + let mut attachments = Vec::with_capacity(attachment_data.len()); + for (data, mime) in &attachment_data { + let (store_data, size) = match &mode { + EncryptionMode::Public => { + (data.clone(), data.len() as u64) + } + EncryptionMode::Recipient { cek, .. } | EncryptionMode::Group { cek, .. } => { + let encrypted = crypto::encrypt_bytes_with_cek(data, cek)?; + let sz = encrypted.len() as u64; + (encrypted, sz) + } + }; + let cid = crate::blob::compute_blob_id(&store_data); + self.blob_store.store(&cid, &store_data)?; + attachments.push(Attachment { + cid, + mime_type: mime.clone(), + size_bytes: size, + }); + } + + // Encrypt content and build visibility + let (final_content, visibility) = match mode { + EncryptionMode::Public => (content, PostVisibility::Public), + EncryptionMode::Recipient { cek, recipients } => { let (encrypted, wrapped_keys) = - crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?; + crypto::encrypt_post_with_cek(&content, &cek, &self.secret_seed, &self.node_id, &recipients)?; ( encrypted, PostVisibility::Encrypted { @@ -591,6 +607,18 @@ impl Node { }, ) } + EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } => { + let (encrypted, wrapped_cek) = + crypto::encrypt_post_for_group_with_cek(&content, &cek, &group_seed, &group_pubkey)?; + ( + encrypted, + PostVisibility::GroupEncrypted { + group_id, + epoch, + wrapped_cek, + }, + ) + } }; let post = Post { @@ -969,6 +997,136 @@ impl Node { Ok(data) } + /// Decrypt a blob in the context of a post's visibility. + /// Public posts pass through unchanged. Encrypted/group-encrypted posts decrypt with the CEK. + fn decrypt_blob_for_post( + &self, + data: Vec, + post: &Post, + visibility: &PostVisibility, + group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>, + ) -> anyhow::Result>> { + match visibility { + PostVisibility::Public => Ok(Some(data)), + PostVisibility::Encrypted { recipients } => { + let cek = crypto::unwrap_cek_for_recipient( + &self.secret_seed, + &self.node_id, + &post.author, + recipients, + )?; + match cek { + Some(cek) => { + let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; + Ok(Some(plaintext)) + } + None => Ok(None), + } + } + PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { + if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) { + let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?; + let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; + Ok(Some(plaintext)) + } else { + Ok(None) + } + } + } + } + + /// Get a blob by CID, decrypting it in the context of the given post. + /// For public posts, returns raw blob data. For encrypted posts, decrypts with the post's CEK. + pub async fn get_blob_for_post( + &self, + cid: &[u8; 32], + post_id: &PostId, + ) -> anyhow::Result>> { + // Get raw blob data (local) + let raw_data = match self.blob_store.get(cid)? { + Some(d) => d, + None => return Ok(None), + }; + { + let storage = self.storage.lock().await; + let _ = storage.touch_blob_access(cid); + } + + // Get post + visibility + let (post, visibility) = { + let storage = self.storage.lock().await; + match storage.get_post_with_visibility(post_id)? { + Some(pv) => pv, + None => return Ok(Some(raw_data)), // No post context — return raw + } + }; + + match &visibility { + PostVisibility::Public => Ok(Some(raw_data)), + PostVisibility::Encrypted { .. } => { + let empty_map = std::collections::HashMap::new(); + self.decrypt_blob_for_post(raw_data, &post, &visibility, &empty_map) + } + PostVisibility::GroupEncrypted { .. } => { + let group_seeds = { + let storage = self.storage.lock().await; + storage.get_all_group_seeds_map().unwrap_or_default() + }; + self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds) + } + } + } + + /// Prefetch blobs for recently synced posts from a peer. + /// Queries storage for posts with attachments missing from the local blob store, + /// then fetches each missing blob. Runs outside any locks. + pub async fn prefetch_blobs_from_peer(&self, peer_id: &NodeId) { + // Gather posts with missing blobs + let missing: Vec<(PostId, NodeId, Vec)> = { + let storage = self.storage.lock().await; + let post_ids = storage.list_post_ids().unwrap_or_default(); + let mut result = Vec::new(); + for pid in post_ids { + if let Ok(Some(post)) = storage.get_post(&pid) { + let missing_atts: Vec<_> = post.attachments.iter() + .filter(|a| !self.blob_store.has(&a.cid)) + .cloned() + .collect(); + if !missing_atts.is_empty() { + result.push((pid, post.author, missing_atts)); + } + } + } + result + }; + + if missing.is_empty() { + return; + } + + let mut fetched = 0usize; + for (post_id, author, attachments) in &missing { + for att in attachments { + match self.fetch_blob_with_fallback( + &att.cid, post_id, author, &att.mime_type, 0, + ).await { + Ok(Some(_)) => { fetched += 1; } + Ok(None) => {} + Err(e) => { + tracing::debug!( + cid = hex::encode(att.cid), + error = %e, + "Blob prefetch failed" + ); + } + } + } + } + if fetched > 0 { + tracing::info!(fetched, peer = hex::encode(peer_id), "Prefetched blobs after sync"); + } + } + /// Check if a blob exists locally. pub fn has_blob(&self, cid: &[u8; 32]) -> bool { self.blob_store.has(cid) @@ -2049,6 +2207,10 @@ impl Node { engagement_headers = engagement, "Sync complete" ); + // Prefetch blobs for posts we just received + if stats.posts_received > 0 { + self.prefetch_blobs_from_peer(&peer_id).await; + } Ok(()) } @@ -2115,15 +2277,15 @@ impl Node { tokio::spawn(async move { network.run_accept_loop().await }) } - /// Start pull cycle: every interval_secs, pull from connected peers. - pub fn start_pull_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { - let network = Arc::clone(&self.network); + /// Start pull cycle: every interval_secs, pull from connected peers + prefetch blobs. + pub fn start_pull_cycle(self: &Arc, interval_secs: u64) -> tokio::task::JoinHandle<()> { + let node = Arc::clone(self); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; - match network.pull_from_all().await { + match node.network.pull_from_all().await { Ok(stats) => { if stats.posts_received > 0 { tracing::debug!( @@ -2131,6 +2293,11 @@ impl Node { peers = stats.peers_pulled, "Pull cycle complete" ); + // Prefetch blobs for newly received posts + let peers = node.network.conn_handle().connected_peers().await; + for peer_id in peers { + node.prefetch_blobs_from_peer(&peer_id).await; + } } } Err(e) => { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 5a24583..64c8a45 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -644,6 +644,23 @@ impl Storage { } } + /// Get the visibility intent for a post (if stored). + pub fn get_post_intent(&self, id: &PostId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT visibility_intent FROM posts WHERE id = ?1", + )?; + let mut rows = stmt.query(params![id.as_slice()])?; + if let Some(row) = rows.next()? { + let intent_json: Option = row.get(0)?; + match intent_json { + Some(json) => Ok(serde_json::from_str(&json).ok()), + None => Ok(None), + } + } else { + Ok(None) + } + } + pub fn list_post_ids(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare("SELECT id FROM posts")?; let rows = stmt.query_map([], |row| { diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index a4c9c23..04885b6 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.3.4" +version = "0.3.5" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 618fdae..9a850d9 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -30,6 +30,8 @@ struct PostDto { is_me: bool, /// "public", "encrypted", or "encrypted-for-me" visibility: String, + /// The original intent: "public", "friends", "circle", "direct", or "unknown" + intent_kind: String, /// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient decrypted_content: Option, attachments: Vec, @@ -158,13 +160,32 @@ async fn post_to_dto( decrypted: Option<&str>, node: &Node, ) -> PostDto { + let is_me = &post.author == &node.node_id; let author_name = match node.resolve_display_name(&post.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; + + // Resolve intent kind from storage + let intent_kind = { + let storage = node.storage.lock().await; + match storage.get_post_intent(id) { + Ok(Some(intent)) => match intent { + VisibilityIntent::Public => "public".to_string(), + VisibilityIntent::Friends => "friends".to_string(), + VisibilityIntent::Circle(_) => "circle".to_string(), + VisibilityIntent::Direct(_) => "direct".to_string(), + }, + _ => "unknown".to_string(), + } + }; + + // For own encrypted posts, show "encrypted" not "encrypted-for-me" + // since intent_kind handles DM filtering now let (visibility, decrypted_content) = match vis { PostVisibility::Public => ("public".to_string(), None), PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted { + Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())), Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())), None => ("encrypted".to_string(), None), }, @@ -203,8 +224,9 @@ async fn post_to_dto( author_name, content: post.content.clone(), timestamp_ms: post.timestamp_ms, - is_me: &post.author == &node.node_id, + is_me, visibility, + intent_kind, decrypted_content, attachments, recipients, @@ -394,16 +416,95 @@ async fn create_post_with_files( } /// Return the filesystem path of a blob if it exists locally (for streaming video/media). +/// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served +/// via the asset protocol — the frontend must use IPC-based get_blob instead. #[tauri::command] async fn get_blob_path( state: State<'_, AppState>, cid_hex: String, + post_id_hex: Option, ) -> Result, String> { + let node = state.inner(); let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - Ok(state.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string())) + + // If a post_id is provided, check if the post is encrypted — if so, can't serve raw file + if let Some(ref pid_hex) = post_id_hex { + if let Ok(pid_bytes) = hex::decode(pid_hex) { + if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) { + let storage = node.storage.lock().await; + if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) { + if !matches!(vis, PostVisibility::Public) { + return Ok(None); + } + } + } + } + } + + Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string())) +} + +/// Sanitize a filename for safe download: strip path separators and control characters. +fn sanitize_download_filename(filename: &str) -> String { + filename + .replace(['/', '\\', '\0'], "_") + .chars() + .filter(|c| !c.is_control()) + .collect::() +} + +/// Helper: resolve a blob with optional decryption via post context. +/// First tries local (with post-based decryption), then falls back to network fetch + decrypt. +async fn resolve_blob_data( + node: &Node, + cid: &[u8; 32], + post_id_hex: Option<&str>, +) -> Result, String> { + // Parse post_id if provided + let post_id = if let Some(pid_hex) = post_id_hex { + let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?; + Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?) + } else { + None + }; + + // Try local blob with decryption + if let Some(ref pid) = post_id { + if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? { + return Ok(data); + } + } else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? { + return Ok(data); + } + + // Try fetching from network if post_id provided + if let Some(pid) = post_id { + let post = { + let storage = node.storage.lock().await; + storage.get_post(&pid).map_err(|e| e.to_string())? + }; + if let Some(post) = post { + let mime = post.attachments.iter() + .find(|a| a.cid == *cid) + .map(|a| a.mime_type.as_str()) + .unwrap_or("application/octet-stream"); + if let Some(_fetched) = node + .fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms) + .await + .map_err(|e| e.to_string())? + { + // Re-read with decryption + if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? { + return Ok(data); + } + } + } + } + + Err("blob not found".to_string()) } /// Save a blob to the Downloads folder and open it with the system handler. @@ -420,36 +521,14 @@ async fn save_and_open_blob( .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - // Get blob data (local or fetch from network) - let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { - d - } else if let Some(pid_hex) = post_id_hex { - let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; - let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?; - let post = { - let storage = node.storage.lock().await; - storage.get_post(&post_id).map_err(|e| e.to_string())? - }; - if let Some(post) = post { - let mime = post.attachments.iter() - .find(|a| a.cid == cid) - .map(|a| a.mime_type.as_str()) - .unwrap_or("application/octet-stream"); - node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms) - .await.map_err(|e| e.to_string())? - .ok_or_else(|| "blob not found".to_string())? - } else { - return Err("post not found".to_string()); - } - } else { - return Err("blob not found".to_string()); - }; + let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + let safe_name = sanitize_download_filename(&filename); // Save to Downloads let downloads = dirs::download_dir() .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); - let dest = downloads.join(&filename); + let dest = downloads.join(&safe_name); tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?; // Open with system handler @@ -472,34 +551,13 @@ async fn save_blob( .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { - d - } else if let Some(pid_hex) = post_id_hex { - let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; - let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?; - let post = { - let storage = node.storage.lock().await; - storage.get_post(&post_id).map_err(|e| e.to_string())? - }; - if let Some(post) = post { - let mime = post.attachments.iter() - .find(|a| a.cid == cid) - .map(|a| a.mime_type.as_str()) - .unwrap_or("application/octet-stream"); - node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms) - .await.map_err(|e| e.to_string())? - .ok_or_else(|| "blob not found".to_string())? - } else { - return Err("post not found".to_string()); - } - } else { - return Err("blob not found".to_string()); - }; + let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + let safe_name = sanitize_download_filename(&filename); let downloads = dirs::download_dir() .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); - let dest = downloads.join(&filename); + let dest = downloads.join(&safe_name); tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?; Ok(dest.to_string_lossy().to_string()) @@ -517,41 +575,9 @@ async fn get_blob( .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - // Check local first (also touches last_accessed_at) - if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { - use base64::Engine; - return Ok(base64::engine::general_purpose::STANDARD.encode(&data)); - } - - // Try fetching from author → replica peers if post_id provided - if let Some(pid_hex) = post_id_hex { - let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; - let post_id: [u8; 32] = pid_bytes - .try_into() - .map_err(|_| "post_id must be 32 bytes".to_string())?; - let post = { - let storage = node.storage.lock().await; - storage.get_post(&post_id).map_err(|e| e.to_string())? - }; - if let Some(post) = post { - // Find the mime type from the post's attachments - let mime_type = post.attachments.iter() - .find(|a| a.cid == cid) - .map(|a| a.mime_type.as_str()) - .unwrap_or("application/octet-stream"); - - if let Some(data) = node - .fetch_blob_with_fallback(&cid, &post_id, &post.author, mime_type, post.timestamp_ms) - .await - .map_err(|e| e.to_string())? - { - use base64::Engine; - return Ok(base64::engine::general_purpose::STANDARD.encode(&data)); - } - } - } - - Err("blob not found".to_string()) + let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.encode(&data)) } #[tauri::command] diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index a9623d3..bbf78ca 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.3.4", + "version": "0.3.5", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index f148850..aeb4438 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -628,7 +628,7 @@ async function loadStats() { async function loadFeed(force) { try { const allPosts = await invoke('get_feed'); - const posts = allPosts.filter(p => p.visibility !== 'encrypted-for-me'); + const posts = allPosts.filter(p => p.intentKind !== 'direct'); // Fingerprint: post IDs + reaction counts + comment counts const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); if (!force && fp === _feedFingerprint) return; @@ -705,7 +705,7 @@ async function loadFeed(force) { async function loadMyPosts(force) { try { const posts = await invoke('get_all_posts'); - const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me' && !(p.recipients && p.recipients.length > 0)); + const mine = posts.filter(p => p.isMe && p.intentKind !== 'direct'); const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|'); if (!force && fp === _myPostsFingerprint) return; _myPostsFingerprint = fp; @@ -743,10 +743,12 @@ async function loadMessages(force) { ]); const followSet = new Set(follows.map(f => f.nodeId)); - // Collect DMs: received encrypted-for-me OR my sent encrypted with recipients + // Collect DMs: intent-based with fallback for old posts without intentKind const dms = posts.filter(p => { - if (!p.isMe && p.visibility === 'encrypted-for-me') return true; - if (p.isMe && p.recipients && p.recipients.length > 0) return true; + if (p.intentKind === 'direct') return true; + // Fallback for pre-intent posts + if (p.intentKind === 'unknown' && !p.isMe && p.visibility === 'encrypted-for-me') return true; + if (p.intentKind === 'unknown' && p.isMe && p.recipients && p.recipients.length > 0) return true; return false; }); @@ -1402,8 +1404,8 @@ async function loadNetworkSummary() { networkSummaryEl.innerHTML = `
${s.totalConnections}Connections
${s.preferredCount}Preferred
-
${s.localCount}Local
-
${s.wideCount}Wide
+
${s.localCount}Mesh
+
${s.wideCount}Non-mesh N1
${s.n2Distinct}N2 Reach
${s.n3Distinct}N3 Reach
`; @@ -1424,9 +1426,11 @@ async function loadConnections() { const icon = generateIdenticon(c.nodeId, 18); const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred' : c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local'; + const slotLabel = c.slotKind === 'Local' ? 'Mesh' + : c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind; const duration = c.connectedAt ? relativeTime(c.connectedAt) : ''; return `
-
${icon} ${label} ${c.slotKind}
+
${icon} ${label} ${slotLabel}
${duration}
`; }).join(''); @@ -1707,7 +1711,7 @@ async function loadPostMedia(container) { const postId = img.dataset.postId; const mime = img.dataset.mime || 'image/jpeg'; try { - const filePath = await invoke('get_blob_path', { cidHex: cid }); + const filePath = await invoke('get_blob_path', { cidHex: cid, postIdHex: postId }); if (filePath && window.__TAURI__?.core?.convertFileSrc) { const assetUrl = window.__TAURI__.core.convertFileSrc(filePath); img.onerror = async () => { diff --git a/website/contribute.html b/website/contribute.html index 7ccc823..e1518b0 100644 --- a/website/contribute.html +++ b/website/contribute.html @@ -10,6 +10,7 @@