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:
parent
a41b11c0b8
commit
b7f2d369fa
9 changed files with 882 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">✓</span>';
|
||||
} else if (bestState === 'seen') {
|
||||
el.innerHTML = '<span class="receipt-check seen" title="Seen">✓✓</span>';
|
||||
} else if (bestState === 'reacted') {
|
||||
el.innerHTML = `<span class="receipt-check reacted" title="Reacted">${escapeHtml(reactionEmoji || '✓✓')}</span>`;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDmRecipientOptions() {
|
||||
try {
|
||||
const [follows, peers] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 — 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 — 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.</p>
|
||||
<p style="margin-top: 0.5rem;"><strong>v0.3.5</strong> (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.</p>
|
||||
<p><strong>v0.3.4</strong> (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.</p>
|
||||
<p><strong>v0.3.3</strong> (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.</p>
|
||||
<p><strong>v0.3.2</strong> (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.</p>
|
||||
|
|
@ -1076,6 +1076,44 @@ FAILURE: C → B → 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 & 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 — only participants with the post’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 — 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 → seen → 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 -->
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@
|
|||
<li><strong>Crypto refactoring</strong> — 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> — 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 “encrypted-for-me.” Backward-compatible with pre-intent posts.</li>
|
||||
<li><strong>Blob decryption on retrieval</strong> — New <code>get_blob_for_post</code> API decrypts private blobs in context of their post’s visibility. Public blobs pass through unchanged.</li>
|
||||
<li><strong>Encrypted receipt slots</strong> — 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 & reactions</strong> — DM conversations show delivery indicators (checkmark → double checkmark → emoji). Opening a conversation marks messages as seen. React to messages with emoji.</li>
|
||||
<li><strong>Private comment slots</strong> — 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> — Prevents path traversal in downloaded file names.</li>
|
||||
</ul>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue