v0.3.5: Encrypted receipt & comment slots, message delivery indicators

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 14:15:33 -04:00
parent a41b11c0b8
commit b7f2d369fa
9 changed files with 882 additions and 4 deletions

View file

@ -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::<crate::types::BlobHeader>(&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::<crate::types::BlobHeader>(&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::<crate::types::BlobHeader>(&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
}
}

View file

@ -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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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));
}
}

View file

@ -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<Vec<u8>> = (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<Vec<u8>> = (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<Option<([u8; 32], Vec<NodeId>)>> {
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<NodeId> = 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<NodeId> = 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<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);
// 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::<crate::types::BlobHeader>(&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::<crate::types::BlobHeader>(&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<Vec<crate::types::ReceiptSlotData>> {
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::<crate::types::BlobHeader>(&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<Vec<crate::types::CommentSlotData>> {
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::<crate::types::BlobHeader>(&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 {

View file

@ -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<u8> },
/// Write an encrypted comment slot (256 bytes encrypted data)
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
/// Add new encrypted comment slots (each 256 bytes)
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
/// 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<PostId>,
/// Encrypted receipt slots (each 64 bytes) — only for encrypted posts
#[serde(default)]
pub receipt_slots: Vec<Vec<u8>>,
/// Encrypted comment slots (each 256 bytes) — only for encrypted posts
#[serde(default)]
pub comment_slots: Vec<Vec<u8>>,
}
/// 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<NodeId>,
pub state: ReceiptState,
pub timestamp_ms: u64,
/// Emoji string (only set when state == Reacted)
pub emoji: Option<String>,
}
/// 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

View file

@ -80,6 +80,26 @@ struct CommentPolicyDto {
blocklist: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReceiptSlotDto {
slot_index: u32,
node_id: Option<String>,
state: String,
timestamp_ms: u64,
emoji: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommentSlotDto {
slot_index: u32,
author: String,
author_name: Option<String>,
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<String>,
) -> 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<Vec<ReceiptSlotDto>, 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<Vec<CommentSlotDto>, 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<itsgoin_core::types::PostId, String> {
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,

View file

@ -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 `<div class="chat-bubble ${side}">
const isEncrypted = p.visibility === 'encrypted-for-me' || (p.recipients && p.recipients.length > 0);
const receiptAttr = isEncrypted ? ` data-post-id="${p.id}"` : '';
const reactBtn = isEncrypted ? `<button class="slot-react-btn" data-post-id="${p.id}" title="React">+</button>` : '';
return `<div class="chat-bubble ${side}"${receiptAttr}>
<div class="chat-text">${escapeHtml(content)}</div>
<div class="chat-time">${msgTime}</div>
<div class="chat-meta"><span class="chat-time">${msgTime}</span>${p.isMe && isEncrypted ? '<span class="receipt-indicator" data-post-id="' + p.id + '"></span>' : ''}${reactBtn}</div>
</div>`;
}).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, `
<div class="chat-window" style="max-height:55vh;overflow-y:auto;display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem 0">${msgsHtml}</div>
<div id="popover-slot-comments" style="max-height:15vh;overflow-y:auto;padding:0.3rem 0;border-top:1px solid #2a2a40;display:none"></div>
<div class="conv-reply" style="display:flex;gap:0.4rem;align-items:flex-end;margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #2a2a40">
<textarea class="conv-reply-input" id="popover-reply-input" placeholder="Reply..." rows="2" style="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"></textarea>
<button class="btn btn-primary btn-sm" id="popover-reply-btn">Send</button>
@ -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 = '<span class="receipt-check" title="Delivered">&#10003;</span>';
} else if (bestState === 'seen') {
el.innerHTML = '<span class="receipt-check seen" title="Seen">&#10003;&#10003;</span>';
} else if (bestState === 'reacted') {
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '&#10003;&#10003;')}</span>`;
}
} catch (_) {}
}
}
async function loadDmRecipientOptions() {
try {
const [follows, peers] = await Promise.all([

View file

@ -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; }

View file

@ -44,7 +44,7 @@
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p>
<div class="card" style="margin-top: 1rem;">
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
<p style="margin-top: 0.5rem;"><strong>v0.3.5</strong> (2026-03-20): Private blob encryption &mdash; 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 &mdash; attachments eagerly fetched after post pull for offline availability. Crypto refactoring &mdash; extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering &mdash; feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization.</p>
<p style="margin-top: 0.5rem;"><strong>v0.3.5</strong> (2026-03-20): Private blob encryption &mdash; 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 &mdash; attachments eagerly fetched after post pull for offline availability. Crypto refactoring &mdash; extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering &mdash; feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt &amp; comment slots &mdash; 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 &mdash; DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.</p>
<p><strong>v0.3.4</strong> (2026-03-18): Comment edit &amp; 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.</p>
<p><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting &mdash; incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning &mdash; PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness &mdash; TTL 7d&rarr;5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery &mdash; 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix &mdash; nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking &mdash; post_upstream table records post source for engagement diff routing toward author. Video preload fix &mdash; 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.</p>
<p><strong>v0.3.2</strong> (2026-03-14): Bidirectional engagement propagation &mdash; 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 &rarr; TCP punch &rarr; QUIC proxy). Video playback fix (asset protocol + blob URL fallback). On-demand blob fetch for synced posts missing blob data.</p>
@ -1076,6 +1076,44 @@ FAILURE: C &rarr; B &rarr; A: AnchorProbeResult { reachable: false }</code></pre
<h3>IPv6 HTTP address advertisement</h3>
<p>Nodes with public IPv6 addresses advertise their actual routable address (from <code>endpoint.addr().ip_addrs()</code>) paired with their bound port, rather than the bind address (<code>0.0.0.0</code>). This enables direct browser-to-node HTTP serving for share links. Unroutable addresses (<code>0.0.0.0</code>, <code>127.x</code>) are filtered out in the tiered web serving redirect path.</p>
<h3>Encrypted receipt &amp; comment slots</h3>
<p>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 &mdash; only participants with the post&rsquo;s CEK can decrypt them.</p>
<h4>Design principles</h4>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Pre-filled noise</strong>: 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.</li>
<li><strong>Slot key derivation</strong>: <code>slot_key = BLAKE3_derive_key("itsgoin/slot/v1", CEK)</code>. Only participants who can decrypt the post can read/write slots.</li>
<li><strong>CDN-safe</strong>: Relay nodes store and propagate slot bytes without decryption. No new protocol messages needed &mdash; slots travel via <code>BlobHeaderDiff</code>.</li>
</ul>
<h4>Receipt slots (64 bytes each)</h4>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Allocation</strong>: 1 per participant (including author)</li>
<li><strong>Slot assignment</strong>: Participants sorted by NodeId; slot index = position in sorted list</li>
<li><strong>Decrypted format</strong>: <code>[1 byte: state][8 bytes: timestamp_ms BE][23 bytes: emoji/padding]</code></li>
<li><strong>States</strong>: 0=empty/noise, 1=delivered, 2=seen, 3=reacted</li>
<li><strong>Author pre-feed</strong>: Author can write their own slot with a reaction emoji at creation time</li>
</ul>
<h4>Comment slots (256 bytes each)</h4>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Allocation</strong>: <code>ceil(participants / 3)</code> initial slots, expandable via <code>AddCommentSlots</code> diff op</li>
<li><strong>Decrypted format</strong>: <code>[32 bytes: author_node_id][8 bytes: timestamp_ms BE][216 bytes: UTF-8 content + zero padding]</code></li>
<li><strong>Slot selection</strong>: First available (all-zero content after decryption = available)</li>
<li><strong>Growth</strong>: When all comment slots are used, any participant can append new noise-filled slots</li>
</ul>
<h4>Wire operations</h4>
<table>
<tr><th>Op</th><th>Purpose</th></tr>
<tr><td><code>WriteReceiptSlot</code></td><td>Update a receipt slot (state change: delivered &rarr; seen &rarr; reacted)</td></tr>
<tr><td><code>WriteCommentSlot</code></td><td>Write encrypted comment to a slot</td></tr>
<tr><td><code>AddCommentSlots</code></td><td>Append new noise-filled comment slots when capacity is exhausted</td></tr>
</table>
<h4>UI</h4>
<p>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.</p>
</section>
<!-- 20. Encryption -->

View file

@ -78,6 +78,9 @@
<li><strong>Crypto refactoring</strong> &mdash; Extracted reusable primitives: <code>encrypt_bytes_with_cek</code>, <code>decrypt_bytes_with_cek</code>, <code>unwrap_cek_for_recipient</code>, <code>unwrap_group_cek</code>. Foundation for encrypted blob storage and future chunk-level encryption.</li>
<li><strong>Intent-based post filtering</strong> &mdash; Feed, My Posts, and Messages now filter on the author's original visibility intent (<code>intentKind</code>) rather than encryption state. Direct messages are identified by intent, not by being &ldquo;encrypted-for-me.&rdquo; Backward-compatible with pre-intent posts.</li>
<li><strong>Blob decryption on retrieval</strong> &mdash; New <code>get_blob_for_post</code> API decrypts private blobs in context of their post&rsquo;s visibility. Public blobs pass through unchanged.</li>
<li><strong>Encrypted receipt slots</strong> &mdash; 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.</li>
<li><strong>Message receipts &amp; reactions</strong> &mdash; DM conversations show delivery indicators (checkmark &rarr; double checkmark &rarr; emoji). Opening a conversation marks messages as seen. React to messages with emoji.</li>
<li><strong>Private comment slots</strong> &mdash; 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.</li>
<li><strong>Download filename sanitization</strong> &mdash; Prevents path traversal in downloaded file names.</li>
</ul>