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