From b7f2d369fa860137773a4c8add403d1b10e2a1d6 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Fri, 20 Mar 2026 14:15:33 -0400 Subject: [PATCH] v0.3.5: Encrypted receipt & comment slots, message delivery indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encrypted slots in BlobHeader: - Private posts get noise-prefilled receipt slots (64B, 1 per participant) and comment slots (256B, ceil(participants/3), expandable) - Slot key derived from post CEK via BLAKE3 — only participants can read - CDN relays propagate opaque encrypted bytes without decryption - 3 new BlobHeaderDiffOps: WriteReceiptSlot, WriteCommentSlot, AddCommentSlots Receipt system: - States: empty(0), delivered(1), seen(2), reacted(3) - Slot index = position in sorted participant NodeId list - Author can pre-feed emoji reaction at creation time - 6 new crypto tests for slot encrypt/decrypt/derivation Node methods: - write_receipt_slot, write_comment_slot with upstream+downstream propagation - read_receipt_slots, read_comment_slots with CEK-based decryption - get_post_cek_and_participants helper for both Encrypted and GroupEncrypted IPC: write_message_receipt, write_message_comment, get_message_receipts, get_message_comments Frontend: - DM chat bubbles show delivery indicators (check → double → blue → emoji) - Opening conversation auto-marks incoming messages as seen - React button on messages with emoji prompt Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/connection.rs | 56 +++++ crates/core/src/crypto.rs | 108 +++++++++ crates/core/src/node.rs | 436 ++++++++++++++++++++++++++++++++++ crates/core/src/types.rs | 71 ++++++ crates/tauri-app/src/lib.rs | 90 +++++++ frontend/app.js | 72 +++++- frontend/style.css | 10 +- website/design.html | 40 +++- website/download.html | 3 + 9 files changed, 882 insertions(+), 4 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index b2dfed8..1c2b97a 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -5516,6 +5516,62 @@ impl ConnectionManager { parent_post_id: payload.post_id, }); } + BlobHeaderDiffOp::WriteReceiptSlot { post_id, slot_index, data } => { + // Store encrypted bytes directly — no decryption needed on relay nodes + if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) { + if let Ok(mut header) = serde_json::from_str::(&json) { + let idx = *slot_index as usize; + while header.receipt_slots.len() <= idx { + header.receipt_slots.push(vec![0u8; 64]); // padding + } + header.receipt_slots[idx] = data.clone(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + header.updated_at = now_ms; + if let Ok(new_json) = serde_json::to_string(&header) { + let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms); + } + } + } + } + BlobHeaderDiffOp::WriteCommentSlot { post_id, slot_index, data } => { + if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) { + if let Ok(mut header) = serde_json::from_str::(&json) { + let idx = *slot_index as usize; + while header.comment_slots.len() <= idx { + header.comment_slots.push(vec![0u8; 256]); + } + header.comment_slots[idx] = data.clone(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + header.updated_at = now_ms; + if let Ok(new_json) = serde_json::to_string(&header) { + let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms); + } + } + } + } + BlobHeaderDiffOp::AddCommentSlots { post_id, count: _, slots } => { + if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) { + if let Ok(mut header) = serde_json::from_str::(&json) { + for slot in slots { + header.comment_slots.push(slot.clone()); + } + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + header.updated_at = now_ms; + if let Ok(new_json) = serde_json::to_string(&header) { + let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms); + } + } + } + } BlobHeaderDiffOp::Unknown => {} // future ops — silently skip } } diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 7b4c940..a8205b2 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -517,6 +517,34 @@ pub fn re_encrypt_post( encrypt_post(&plaintext, our_seed, our_node_id, new_recipient_ids) } +// --- Slot encryption (receipt + comment slots for encrypted posts) --- + +const SLOT_KEY_CONTEXT: &str = "itsgoin/slot/v1"; + +/// Derive the slot encryption key from a post's CEK. +/// Only participants who can unwrap the CEK can derive this key. +pub fn derive_slot_key(cek: &[u8; 32]) -> [u8; 32] { + blake3::derive_key(SLOT_KEY_CONTEXT, cek) +} + +/// Encrypt a slot's plaintext bytes using the slot key (derived from CEK). +/// Returns encrypted payload via encrypt_bytes_with_cek. +pub fn encrypt_slot(plaintext: &[u8], slot_key: &[u8; 32]) -> Result> { + encrypt_bytes_with_cek(plaintext, slot_key) +} + +/// Decrypt a slot's encrypted bytes using the slot key (derived from CEK). +pub fn decrypt_slot(encrypted: &[u8], slot_key: &[u8; 32]) -> Result> { + decrypt_bytes_with_cek(encrypted, slot_key) +} + +/// Generate a random noise-filled slot (indistinguishable from encrypted data). +pub fn random_slot_noise(size: usize) -> Vec { + let mut buf = vec![0u8; size]; + rand::rng().fill_bytes(&mut buf); + buf +} + // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; @@ -970,4 +998,84 @@ mod tests { let result = decrypt_group_post(&encrypted_v2, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v2); assert!(result.is_err()); } + + #[test] + fn test_slot_key_derivation_deterministic() { + let cek = [42u8; 32]; + let key1 = derive_slot_key(&cek); + let key2 = derive_slot_key(&cek); + assert_eq!(key1, key2); + // Different CEK gives different slot key + let cek2 = [99u8; 32]; + let key3 = derive_slot_key(&cek2); + assert_ne!(key1, key3); + } + + #[test] + fn test_slot_encrypt_decrypt_roundtrip() { + let cek = [42u8; 32]; + let slot_key = derive_slot_key(&cek); + let plaintext = b"hello slot encryption"; + let encrypted = encrypt_slot(plaintext, &slot_key).unwrap(); + let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_slot_wrong_key_fails() { + let cek = [42u8; 32]; + let slot_key = derive_slot_key(&cek); + let wrong_key = derive_slot_key(&[99u8; 32]); + let plaintext = b"secret data"; + let encrypted = encrypt_slot(plaintext, &slot_key).unwrap(); + assert!(decrypt_slot(&encrypted, &wrong_key).is_err()); + } + + #[test] + fn test_receipt_slot_roundtrip() { + let cek = [42u8; 32]; + let slot_key = derive_slot_key(&cek); + + // Build a receipt: state=seen, timestamp, no emoji + let mut plaintext = [0u8; 32]; + plaintext[0] = 2; // seen + let ts: u64 = 1700000000000; + plaintext[1..9].copy_from_slice(&ts.to_le_bytes()); + + let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap(); + let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); + assert_eq!(decrypted[0], 2); + assert_eq!(u64::from_le_bytes(decrypted[1..9].try_into().unwrap()), ts); + } + + #[test] + fn test_comment_slot_roundtrip() { + let cek = [42u8; 32]; + let slot_key = derive_slot_key(&cek); + + let mut plaintext = [0u8; 256]; + let author = [1u8; 32]; + plaintext[..32].copy_from_slice(&author); + let ts: u64 = 1700000000000; + plaintext[32..40].copy_from_slice(&ts.to_le_bytes()); + let content = b"Hello from a slot comment!"; + plaintext[40..40 + content.len()].copy_from_slice(content); + + let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap(); + let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap(); + assert_eq!(&decrypted[..32], &author); + assert_eq!(u64::from_le_bytes(decrypted[32..40].try_into().unwrap()), ts); + let end = decrypted[40..].iter().position(|&b| b == 0).unwrap_or(216); + assert_eq!(&decrypted[40..40 + end], content); + } + + #[test] + fn test_random_slot_noise_correct_size() { + let noise64 = random_slot_noise(64); + assert_eq!(noise64.len(), 64); + let noise256 = random_slot_noise(256); + assert_eq!(noise256.len(), 256); + // Different calls produce different noise (with very high probability) + assert_ne!(random_slot_noise(64), random_slot_noise(64)); + } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index cf96371..8d3f6a8 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -638,6 +638,48 @@ impl Node { // Auto-pin own blobs so they're never evicted before foreign content let _ = storage.pin_blob(&att.cid); } + + // Initialize encrypted receipt + comment slots for non-public posts + if !matches!(visibility, PostVisibility::Public) { + let participant_count = match &visibility { + PostVisibility::Encrypted { recipients } => recipients.len(), + PostVisibility::GroupEncrypted { .. } => { + // For group posts, we don't know exact member count at creation time; + // use a reasonable default (the circle members count, resolved earlier) + match &intent { + VisibilityIntent::Circle(circle_name) => { + storage.get_circle_members(circle_name) + .map(|m| m.len() + 1) // +1 for author + .unwrap_or(2) + } + _ => 2, + } + } + PostVisibility::Public => unreachable!(), + }; + + let receipt_slots: Vec> = (0..participant_count) + .map(|_| crypto::random_slot_noise(64)) + .collect(); + let comment_slot_count = (participant_count + 2) / 3; // ceil(participants / 3) + let comment_slots: Vec> = (0..comment_slot_count) + .map(|_| crypto::random_slot_noise(256)) + .collect(); + + let blob_header = crate::types::BlobHeader { + post_id, + author: self.node_id, + reactions: vec![], + comments: vec![], + policy: Default::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots, + comment_slots, + }; + let header_json = serde_json::to_string(&blob_header)?; + storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?; + } } // Build and store CDN manifests for blobs @@ -3546,6 +3588,400 @@ impl Node { Ok(comments) } + + // --- Encrypted receipt/comment slot methods --- + + /// Unwrap the CEK for a post we are a participant of, returning (cek, sorted_participants). + /// Returns None if this is a public post or we cannot decrypt. + async fn get_post_cek_and_participants( + &self, + post_id: &PostId, + ) -> anyhow::Result)>> { + let storage = self.storage.lock().await; + let (post, visibility) = match storage.get_post_with_visibility(post_id)? { + Some(pv) => pv, + None => return Ok(None), + }; + drop(storage); + + match &visibility { + PostVisibility::Public => Ok(None), + PostVisibility::Encrypted { recipients } => { + let cek = crypto::unwrap_cek_for_recipient( + &self.secret_seed, + &self.node_id, + &post.author, + recipients, + )?; + match cek { + Some(cek) => { + let mut participants: Vec = recipients.iter().map(|wk| wk.recipient).collect(); + participants.sort(); + participants.dedup(); + Ok(Some((cek, participants))) + } + None => Ok(None), + } + } + PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { + let storage = self.storage.lock().await; + let group_seeds = storage.get_all_group_seeds_map().unwrap_or_default(); + let group_key_record = storage.get_group_key(group_id)?; + let members = if let Some(ref gk) = group_key_record { + storage.get_circle_members(&gk.circle_name).unwrap_or_default() + } else { + vec![] + }; + drop(storage); + + if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) { + let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?; + let mut participants: Vec = members; + // Ensure the author is included + if !participants.contains(&post.author) { + participants.push(post.author); + } + participants.sort(); + participants.dedup(); + Ok(Some((cek, participants))) + } else { + Ok(None) + } + } + } + } + + /// Write our receipt slot for an encrypted post. + /// `state` is the receipt state, `emoji` is optional (only used when state == Reacted). + pub async fn write_receipt_slot( + &self, + post_id: PostId, + state: crate::types::ReceiptState, + emoji: Option, + ) -> anyhow::Result<()> { + let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; + let slot_key = crypto::derive_slot_key(&cek); + + // Find our slot index (sorted participant position) + let our_slot = participants.iter().position(|nid| nid == &self.node_id) + .ok_or_else(|| anyhow::anyhow!("our node_id not found in participants"))?; + + // Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding] + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let mut plaintext = [0u8; 32]; + plaintext[0] = state as u8; + plaintext[1..9].copy_from_slice(&now.to_le_bytes()); + if let Some(ref emoji_str) = emoji { + let emoji_bytes = emoji_str.as_bytes(); + let copy_len = emoji_bytes.len().min(23); + plaintext[9..9 + copy_len].copy_from_slice(&emoji_bytes[..copy_len]); + } + + let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?; + + // Update the BlobHeader + let storage = self.storage.lock().await; + let header = storage.get_blob_header(&post_id)?; + let mut blob_header = if let Some((json, _ts)) = header { + serde_json::from_str::(&json) + .unwrap_or_else(|_| crate::types::BlobHeader { + post_id, + author: self.node_id, + reactions: vec![], + comments: vec![], + policy: Default::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + }) + } else { + crate::types::BlobHeader { + post_id, + author: self.node_id, + reactions: vec![], + comments: vec![], + policy: Default::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + } + }; + + // Ensure enough slots exist + while blob_header.receipt_slots.len() <= our_slot { + blob_header.receipt_slots.push(crypto::random_slot_noise(64)); + } + blob_header.receipt_slots[our_slot] = encrypted.clone(); + blob_header.updated_at = now; + + let header_json = serde_json::to_string(&blob_header)?; + storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?; + drop(storage); + + // Propagate via BlobHeaderDiff + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: self.node_id, + ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot { + post_id, + slot_index: our_slot as u32, + data: encrypted, + }], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + let upstream = { + let storage = self.storage.lock().await; + storage.get_post_upstream(&post_id).ok().flatten() + }; + if let Some(up) = upstream { + let _ = self.network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await; + } + + Ok(()) + } + + /// Write a private comment to an encrypted post's comment slot. + pub async fn write_comment_slot( + &self, + post_id: PostId, + content: String, + ) -> anyhow::Result<()> { + let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; + let slot_key = crypto::derive_slot_key(&cek); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + // Build plaintext: [32 bytes author_node_id][8 bytes timestamp_ms][216 bytes content+padding] + let mut plaintext = [0u8; 256]; + plaintext[..32].copy_from_slice(&self.node_id); + plaintext[32..40].copy_from_slice(&now.to_le_bytes()); + let content_bytes = content.as_bytes(); + let copy_len = content_bytes.len().min(216); + plaintext[40..40 + copy_len].copy_from_slice(&content_bytes[..copy_len]); + + let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?; + + // Find first available comment slot or add new ones + let storage = self.storage.lock().await; + let header = storage.get_blob_header(&post_id)?; + let mut blob_header = if let Some((json, _ts)) = header { + serde_json::from_str::(&json) + .unwrap_or_else(|_| crate::types::BlobHeader { + post_id, + author: self.node_id, + reactions: vec![], + comments: vec![], + policy: Default::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + }) + } else { + crate::types::BlobHeader { + post_id, + author: self.node_id, + reactions: vec![], + comments: vec![], + policy: Default::default(), + updated_at: now, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + } + }; + + // Try to find an empty slot by attempting decryption + let mut target_index = None; + for (i, slot) in blob_header.comment_slots.iter().enumerate() { + if let Ok(decrypted) = crypto::decrypt_slot(slot, &slot_key) { + // Check if all 256 plaintext bytes are zero (empty) + if decrypted.len() == 256 && decrypted.iter().all(|&b| b == 0) { + target_index = Some(i); + break; + } + } else { + // Cannot decrypt — could be random noise (empty), use it + target_index = Some(i); + break; + } + } + + let (slot_index, add_new) = if let Some(idx) = target_index { + (idx, false) + } else { + // No available slots — add one + let idx = blob_header.comment_slots.len(); + blob_header.comment_slots.push(crypto::random_slot_noise(256)); + (idx, true) + }; + + blob_header.comment_slots[slot_index] = encrypted.clone(); + blob_header.updated_at = now; + + let header_json = serde_json::to_string(&blob_header)?; + storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?; + drop(storage); + + // Propagate + let op = if add_new { + crate::types::BlobHeaderDiffOp::AddCommentSlots { + post_id, + count: 1, + slots: vec![encrypted], + } + } else { + crate::types::BlobHeaderDiffOp::WriteCommentSlot { + post_id, + slot_index: slot_index as u32, + data: encrypted, + } + }; + + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: self.node_id, + ops: vec![op], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + let upstream = { + let storage = self.storage.lock().await; + storage.get_post_upstream(&post_id).ok().flatten() + }; + if let Some(up) = upstream { + let _ = self.network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await; + } + + Ok(()) + } + + /// Read and decrypt all receipt slots for an encrypted post. + pub async fn read_receipt_slots( + &self, + post_id: PostId, + ) -> anyhow::Result> { + let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; + let slot_key = crypto::derive_slot_key(&cek); + + let storage = self.storage.lock().await; + let header = storage.get_blob_header(&post_id)?; + drop(storage); + + let blob_header = match header { + Some((json, _ts)) => serde_json::from_str::(&json)?, + None => return Ok(vec![]), + }; + + let mut results = Vec::new(); + for (i, slot) in blob_header.receipt_slots.iter().enumerate() { + let participant_id = participants.get(i).copied(); + match crypto::decrypt_slot(slot, &slot_key) { + Ok(plaintext) if plaintext.len() >= 9 => { + let state = crate::types::ReceiptState::from_u8(plaintext[0]); + let timestamp_ms = u64::from_le_bytes( + plaintext[1..9].try_into().unwrap_or([0u8; 8]), + ); + let emoji = if state == crate::types::ReceiptState::Reacted && plaintext.len() >= 32 { + let emoji_bytes = &plaintext[9..32]; + let end = emoji_bytes.iter().position(|&b| b == 0).unwrap_or(23); + if end > 0 { + String::from_utf8(emoji_bytes[..end].to_vec()).ok() + } else { + None + } + } else { + None + }; + results.push(crate::types::ReceiptSlotData { + slot_index: i as u32, + node_id: participant_id, + state, + timestamp_ms, + emoji, + }); + } + _ => { + // Could not decrypt — noise/uninitialized slot + results.push(crate::types::ReceiptSlotData { + slot_index: i as u32, + node_id: participant_id, + state: crate::types::ReceiptState::Empty, + timestamp_ms: 0, + emoji: None, + }); + } + } + } + + Ok(results) + } + + /// Read and decrypt all comment slots for an encrypted post. + pub async fn read_comment_slots( + &self, + post_id: PostId, + ) -> anyhow::Result> { + let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; + let slot_key = crypto::derive_slot_key(&cek); + + let storage = self.storage.lock().await; + let header = storage.get_blob_header(&post_id)?; + drop(storage); + + let blob_header = match header { + Some((json, _ts)) => serde_json::from_str::(&json)?, + None => return Ok(vec![]), + }; + + let mut results = Vec::new(); + for (i, slot) in blob_header.comment_slots.iter().enumerate() { + match crypto::decrypt_slot(slot, &slot_key) { + Ok(plaintext) if plaintext.len() >= 40 => { + // Check if it's an empty slot (all zeros) + if plaintext.iter().all(|&b| b == 0) { + continue; + } + let mut author = [0u8; 32]; + author.copy_from_slice(&plaintext[..32]); + // Skip if author is all zeros (empty) + if author == [0u8; 32] { + continue; + } + let timestamp_ms = u64::from_le_bytes( + plaintext[32..40].try_into().unwrap_or([0u8; 8]), + ); + let content_bytes = &plaintext[40..]; + let end = content_bytes.iter().position(|&b| b == 0).unwrap_or(content_bytes.len()); + let content = String::from_utf8_lossy(&content_bytes[..end]).to_string(); + + results.push(crate::types::CommentSlotData { + slot_index: i as u32, + author, + timestamp_ms, + content, + }); + } + _ => { + // Cannot decrypt or too short — skip + } + } + } + + results.sort_by_key(|c| c.timestamp_ms); + Ok(results) + } } pub struct NodeStats { diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 26d4ef1..f3c7120 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -821,6 +821,12 @@ pub enum BlobHeaderDiffOp { DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 }, SetPolicy(CommentPolicy), ThreadSplit { new_post_id: PostId }, + /// Write an encrypted receipt slot (64 bytes encrypted data) + WriteReceiptSlot { post_id: PostId, slot_index: u32, data: Vec }, + /// Write an encrypted comment slot (256 bytes encrypted data) + WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec }, + /// Add new encrypted comment slots (each 256 bytes) + AddCommentSlots { post_id: PostId, count: u32, slots: Vec> }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown, @@ -838,6 +844,71 @@ pub struct BlobHeader { /// PostIds of split-off comment overflow posts #[serde(default)] pub thread_splits: Vec, + /// Encrypted receipt slots (each 64 bytes) — only for encrypted posts + #[serde(default)] + pub receipt_slots: Vec>, + /// Encrypted comment slots (each 256 bytes) — only for encrypted posts + #[serde(default)] + pub comment_slots: Vec>, +} + +/// Receipt slot state byte values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum ReceiptState { + Empty = 0, + Delivered = 1, + Seen = 2, + Reacted = 3, +} + +impl ReceiptState { + pub fn from_u8(v: u8) -> Self { + match v { + 1 => ReceiptState::Delivered, + 2 => ReceiptState::Seen, + 3 => ReceiptState::Reacted, + _ => ReceiptState::Empty, + } + } + + pub fn from_str_label(s: &str) -> Self { + match s { + "delivered" => ReceiptState::Delivered, + "seen" => ReceiptState::Seen, + "reacted" => ReceiptState::Reacted, + _ => ReceiptState::Empty, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ReceiptState::Empty => "empty", + ReceiptState::Delivered => "delivered", + ReceiptState::Seen => "seen", + ReceiptState::Reacted => "reacted", + } + } +} + +/// Decrypted receipt slot data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReceiptSlotData { + pub slot_index: u32, + pub node_id: Option, + pub state: ReceiptState, + pub timestamp_ms: u64, + /// Emoji string (only set when state == Reacted) + pub emoji: Option, +} + +/// Decrypted comment slot data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentSlotData { + pub slot_index: u32, + pub author: NodeId, + pub timestamp_ms: u64, + pub content: String, } /// Links a split-off comment post back to its original parent diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 9a850d9..bb756c0 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -80,6 +80,26 @@ struct CommentPolicyDto { blocklist: Vec, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ReceiptSlotDto { + slot_index: u32, + node_id: Option, + state: String, + timestamp_ms: u64, + emoji: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CommentSlotDto { + slot_index: u32, + author: String, + author_name: Option, + timestamp_ms: u64, + content: String, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CircleDto { @@ -1717,6 +1737,72 @@ async fn get_comment_thread( Ok(dtos) } +#[tauri::command] +async fn write_message_receipt( + state: State<'_, AppState>, + post_id: String, + receipt_state: String, + emoji: Option, +) -> Result<(), String> { + let node = state.inner(); + let pid = hex_to_postid(&post_id)?; + let state_val = itsgoin_core::types::ReceiptState::from_str_label(&receipt_state); + node.write_receipt_slot(pid, state_val, emoji).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn write_message_comment( + state: State<'_, AppState>, + post_id: String, + content: String, +) -> Result<(), String> { + let node = state.inner(); + let pid = hex_to_postid(&post_id)?; + node.write_comment_slot(pid, content).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn get_message_receipts( + state: State<'_, AppState>, + post_id: String, +) -> Result, String> { + let node = state.inner(); + let pid = hex_to_postid(&post_id)?; + let slots = node.read_receipt_slots(pid).await.map_err(|e| e.to_string())?; + Ok(slots.into_iter().map(|s| ReceiptSlotDto { + slot_index: s.slot_index, + node_id: s.node_id.map(hex::encode), + state: s.state.as_str().to_string(), + timestamp_ms: s.timestamp_ms, + emoji: s.emoji, + }).collect()) +} + +#[tauri::command] +async fn get_message_comments( + state: State<'_, AppState>, + post_id: String, +) -> Result, String> { + let node = state.inner(); + let pid = hex_to_postid(&post_id)?; + let slots = node.read_comment_slots(pid).await.map_err(|e| e.to_string())?; + let mut dtos = Vec::new(); + for s in slots { + let author_name = match node.resolve_display_name(&s.author).await { + Ok((name, _, _)) if !name.is_empty() => Some(name), + _ => None, + }; + dtos.push(CommentSlotDto { + slot_index: s.slot_index, + author: hex::encode(s.author), + author_name, + timestamp_ms: s.timestamp_ms, + content: s.content, + }); + } + Ok(dtos) +} + fn hex_to_postid(hex_str: &str) -> Result { let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex: {}", e))?; if bytes.len() != 32 { @@ -1874,6 +1960,10 @@ pub fn run() { set_comment_policy, get_comment_policy, get_comment_thread, + write_message_receipt, + write_message_comment, + get_message_receipts, + get_message_comments, get_setting, set_setting, generate_share_link, diff --git a/frontend/app.js b/frontend/app.js index aeb4438..375b95e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -843,9 +843,12 @@ async function loadMessages(force) { const content = p.decryptedContent || p.content || ''; const msgTime = relativeTime(p.timestampMs); const side = p.isMe ? 'chat-mine' : 'chat-theirs'; - return `
+ const isEncrypted = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0); + const receiptAttr = isEncrypted ? ` data-post-id="${p.id}"` : ''; + const reactBtn = isEncrypted ? `` : ''; + return `
${escapeHtml(content)}
-
${msgTime}
+
${msgTime}${p.isMe && isEncrypted ? '' : ''}${reactBtn}
`; }).join(''); @@ -872,8 +875,15 @@ async function loadMessages(force) { const msgsHtml = item.querySelector('.chat-window').innerHTML; const partnerName = item.querySelector('.conv-name').textContent; + // Collect post IDs for receipt/seen tracking + const threadPostIds = thread.posts.filter(p => { + const enc = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0); + return enc; + }).map(p => p.id); + openPopover(partnerName, `
${msgsHtml}
+
@@ -886,6 +896,34 @@ async function loadMessages(force) { // Focus reply const input = $('#popover-reply-input'); if (input) setTimeout(() => input.focus(), 100); + + // Mark incoming encrypted messages as "seen" + for (const p of thread.posts) { + if (!p.isMe && threadPostIds.includes(p.id)) { + invoke('write_message_receipt', { postId: p.id, receiptState: 'seen' }).catch(() => {}); + } + } + + // Load receipt indicators for sent messages + loadReceiptIndicators(chatWindow); + + // Wire react buttons + chatWindow.querySelectorAll('.slot-react-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const pid = btn.dataset.postId; + const emoji = prompt('Emoji reaction:'); + if (emoji && emoji.trim()) { + try { + await invoke('write_message_receipt', { postId: pid, receiptState: 'reacted', emoji: emoji.trim() }); + toast('Reacted!'); + } catch (err) { + toast('Error: ' + err); + } + } + }); + }); + // Wire send const sendReply = async () => { const content = input.value.trim(); @@ -928,6 +966,36 @@ async function loadMessages(force) { } } +// Load receipt indicators (checkmarks) for sent messages in a chat window +async function loadReceiptIndicators(chatWindow) { + if (!chatWindow) return; + const indicators = chatWindow.querySelectorAll('.receipt-indicator[data-post-id]'); + for (const el of indicators) { + const postId = el.dataset.postId; + try { + const receipts = await invoke('get_message_receipts', { postId }); + if (!receipts || receipts.length === 0) continue; + // Find the best state among non-self receipts + let bestState = 'empty'; + let reactionEmoji = null; + for (const r of receipts) { + if (r.nodeId && r.nodeId !== myNodeId) { + if (r.state === 'reacted') { bestState = 'reacted'; reactionEmoji = r.emoji; break; } + if (r.state === 'seen' && bestState !== 'reacted') bestState = 'seen'; + if (r.state === 'delivered' && bestState === 'empty') bestState = 'delivered'; + } + } + if (bestState === 'delivered') { + el.innerHTML = ''; + } else if (bestState === 'seen') { + el.innerHTML = '✓✓'; + } else if (bestState === 'reacted') { + el.innerHTML = `${escapeHtml(reactionEmoji || '✓✓')}`; + } + } catch (_) {} + } +} + async function loadDmRecipientOptions() { try { const [follows, peers] = await Promise.all([ diff --git a/frontend/style.css b/frontend/style.css index f69ea91..f110982 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -286,8 +286,16 @@ header h1 { font-size: 1.4rem; color: #7fdbca; } .chat-mine { align-self: flex-end; background: #0f3460; color: #e0e0e0; border-bottom-right-radius: 4px; } .chat-theirs { align-self: flex-start; background: #1e2040; color: #dde; border-bottom-left-radius: 4px; } .chat-text { white-space: pre-wrap; } -.chat-time { font-size: 0.6rem; color: #778; margin-top: 0.2rem; text-align: right; } +.chat-meta { display: flex; align-items: center; gap: 0.3rem; justify-content: flex-end; margin-top: 0.15rem; } +.chat-theirs .chat-meta { justify-content: flex-start; } +.chat-time { font-size: 0.6rem; color: #778; text-align: right; } .chat-theirs .chat-time { text-align: left; } +.receipt-indicator { display: inline-block; min-width: 1em; } +.receipt-check { font-size: 0.65rem; color: #778; letter-spacing: -0.15em; } +.receipt-check.seen { color: #7fdbca; } +.receipt-check.reacted { color: #f0a; letter-spacing: 0; } +.slot-react-btn { background: none; border: 1px solid #444; color: #aab; border-radius: 10px; font-size: 0.6rem; padding: 0 0.3rem; cursor: pointer; line-height: 1.4; opacity: 0; transition: opacity 0.15s; } +.chat-bubble:hover .slot-react-btn { opacity: 1; } .conv-reply { display: flex; gap: 0.4rem; align-items: flex-end; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid #2a2a40; } .conv-reply-input { flex: 1; padding: 0.4rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; resize: none; font-family: inherit; font-size: 0.85rem; min-height: 36px; line-height: 1.4; transition: border-color 0.15s; } diff --git a/website/design.html b/website/design.html index 1d6c2c2..4af2104 100644 --- a/website/design.html +++ b/website/design.html @@ -44,7 +44,7 @@

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.

Changelog -

v0.3.5 (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization.

+

v0.3.5 (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt & comment slots — private posts carry noise-prefilled encrypted slots in BlobHeader for delivery/read/react receipts and private comments; CDN-propagated as opaque bytes; slot key derived from post CEK; 3 new BlobHeaderDiffOps (WriteReceiptSlot, WriteCommentSlot, AddCommentSlots). Message UI — DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.

v0.3.4 (2026-03-18): Comment edit & delete with trust-based propagation. Native notifications via Tauri plugin (messages, posts, reactions, comments). Forward-compatible BlobHeaderDiffOp::Unknown variant. Following Online/Offline lightbox. Comment threading scoping fix. Dropdown text legibility fix. Mobile hamburger nav for website.

v0.3.3 (2026-03-16): Connection rate limiting — incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning — PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness — TTL 7d→5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery — 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix — nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking — post_upstream table records post source for engagement diff routing toward author. Video preload fix — share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.

v0.3.2 (2026-03-14): Bidirectional engagement propagation — BlobHeaderDiff flows upstream + downstream through CDN tree. Auto downstream registration on pull sync/push notification. TCP hole punch protocol (TcpPunchRequest/Result 0xD6/0xD7). Tiered web serving (redirect → TCP punch → QUIC proxy). Video playback fix (asset protocol + blob URL fallback). On-demand blob fetch for synced posts missing blob data.

@@ -1076,6 +1076,44 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }IPv6 HTTP address advertisement

Nodes with public IPv6 addresses advertise their actual routable address (from endpoint.addr().ip_addrs()) paired with their bound port, rather than the bind address (0.0.0.0). This enables direct browser-to-node HTTP serving for share links. Unroutable addresses (0.0.0.0, 127.x) are filtered out in the tiered web serving redirect path.

+ +

Encrypted receipt & comment slots

+

Private posts (Friends, Circle, Direct) carry encrypted slots in their BlobHeader for delivery receipts, read receipts, reactions, and private comments. The CDN propagates these as opaque bytes — only participants with the post’s CEK can decrypt them.

+ +

Design principles

+
    +
  • Pre-filled noise: All slots are filled with random bytes on post creation. Writing to a slot replaces noise with encrypted content of the same size, making writes indistinguishable from creation to observers.
  • +
  • Slot key derivation: slot_key = BLAKE3_derive_key("itsgoin/slot/v1", CEK). Only participants who can decrypt the post can read/write slots.
  • +
  • CDN-safe: Relay nodes store and propagate slot bytes without decryption. No new protocol messages needed — slots travel via BlobHeaderDiff.
  • +
+ +

Receipt slots (64 bytes each)

+
    +
  • Allocation: 1 per participant (including author)
  • +
  • Slot assignment: Participants sorted by NodeId; slot index = position in sorted list
  • +
  • Decrypted format: [1 byte: state][8 bytes: timestamp_ms BE][23 bytes: emoji/padding]
  • +
  • States: 0=empty/noise, 1=delivered, 2=seen, 3=reacted
  • +
  • Author pre-feed: Author can write their own slot with a reaction emoji at creation time
  • +
+ +

Comment slots (256 bytes each)

+
    +
  • Allocation: ceil(participants / 3) initial slots, expandable via AddCommentSlots diff op
  • +
  • Decrypted format: [32 bytes: author_node_id][8 bytes: timestamp_ms BE][216 bytes: UTF-8 content + zero padding]
  • +
  • Slot selection: First available (all-zero content after decryption = available)
  • +
  • Growth: When all comment slots are used, any participant can append new noise-filled slots
  • +
+ +

Wire operations

+ + + + + +
OpPurpose
WriteReceiptSlotUpdate a receipt slot (state change: delivered → seen → reacted)
WriteCommentSlotWrite encrypted comment to a slot
AddCommentSlotsAppend new noise-filled comment slots when capacity is exhausted
+ +

UI

+

DM conversations display delivery indicators: single checkmark (sent), double checkmark (delivered/on device), blue double checkmark (seen), emoji (reacted). Opening a conversation auto-marks incoming messages as seen. Messages have a react button for emoji responses.

diff --git a/website/download.html b/website/download.html index b0fbbee..ef75f7b 100644 --- a/website/download.html +++ b/website/download.html @@ -78,6 +78,9 @@
  • Crypto refactoring — Extracted reusable primitives: encrypt_bytes_with_cek, decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek. Foundation for encrypted blob storage and future chunk-level encryption.
  • Intent-based post filtering — Feed, My Posts, and Messages now filter on the author's original visibility intent (intentKind) rather than encryption state. Direct messages are identified by intent, not by being “encrypted-for-me.” Backward-compatible with pre-intent posts.
  • Blob decryption on retrieval — New get_blob_for_post API decrypts private blobs in context of their post’s visibility. Public blobs pass through unchanged.
  • +
  • Encrypted receipt slots — Private messages get encrypted receipt and comment slots in their BlobHeader. Pre-filled with random noise so slot writes are indistinguishable from creation. Receipt states: delivered, seen, reacted. Only participants with the CEK can read slots; relay nodes propagate opaque bytes.
  • +
  • Message receipts & reactions — DM conversations show delivery indicators (checkmark → double checkmark → emoji). Opening a conversation marks messages as seen. React to messages with emoji.
  • +
  • Private comment slots — Encrypted comment capacity in private post headers (ceil(participants/3) slots, expandable). Participants can write short comments that propagate via CDN without revealing content to relays.
  • Download filename sanitization — Prevents path traversal in downloaded file names.