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
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue