v0.0.6: Delivery receipts (sent/delivered/read)

Protocol:
- WireMessage::Receipt { sender_fingerprint, message_id, receipt_type }
- ReceiptType enum: Delivered, Read
- id field added to KeyExchange and Message variants
- Receipts are plaintext (not encrypted) — contain only ID + type

Web client:
- Auto-sends Delivered receipt on successful decrypt
- Tracks sent message IDs with receipt status
- Displays: ✓ (sent, gray), ✓✓ (delivered, white), ✓✓ (read, blue)
- Receipt indicators update live via DOM reference

CLI TUI:
- Auto-sends Delivered receipt back to sender on decrypt
- Tracks receipt status per message ID
- Displays receipt indicators after sent messages

WASM:
- create_receipt() function for web client
- encrypt_with_id/encrypt_key_exchange_with_id for tracking
- decrypt_wire_message handles Receipt variant

17/17 protocol tests pass. Zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 10:12:43 +04:00
parent 8fad8d8374
commit 104ba78b85
8 changed files with 395 additions and 79 deletions

View File

@@ -6,7 +6,7 @@
use wasm_bindgen::prelude::*;
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
use warzone_protocol::message::WireMessage;
use warzone_protocol::message::{ReceiptType, WireMessage};
use warzone_protocol::prekey::{
generate_signed_pre_key, PreKeyBundle,
};
@@ -155,6 +155,16 @@ impl WasmSession {
identity: &WasmIdentity,
their_bundle_bytes: &[u8],
plaintext: &str,
) -> Result<Vec<u8>, JsValue> {
self.encrypt_key_exchange_with_id(identity, their_bundle_bytes, plaintext, &uuid::Uuid::new_v4().to_string())
}
pub fn encrypt_key_exchange_with_id(
&mut self,
identity: &WasmIdentity,
their_bundle_bytes: &[u8],
plaintext: &str,
msg_id: &str,
) -> Result<Vec<u8>, JsValue> {
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
@@ -165,6 +175,7 @@ impl WasmSession {
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
let wire = WireMessage::KeyExchange {
id: msg_id.to_string(),
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
ephemeral_public: *result.ephemeral_public.as_bytes(),
@@ -175,9 +186,14 @@ impl WasmSession {
}
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
self.encrypt_with_id(identity, plaintext, &uuid::Uuid::new_v4().to_string())
}
pub fn encrypt_with_id(&mut self, identity: &WasmIdentity, plaintext: &str, msg_id: &str) -> Result<Vec<u8>, JsValue> {
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
let wire = WireMessage::Message {
id: msg_id.to_string(),
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
ratchet_message: encrypted,
};
@@ -198,6 +214,30 @@ impl WasmSession {
}
}
// ── Receipt creation ──
/// Create a Receipt wire message (plaintext, not encrypted).
/// `receipt_type`: "delivered" or "read".
/// Returns bincode-serialized bytes.
#[wasm_bindgen]
pub fn create_receipt(
sender_fingerprint: &str,
message_id: &str,
receipt_type: &str,
) -> Result<Vec<u8>, JsValue> {
let rt = match receipt_type {
"delivered" => ReceiptType::Delivered,
"read" => ReceiptType::Read,
_ => return Err(JsValue::from_str("receipt_type must be 'delivered' or 'read'")),
};
let wire = WireMessage::Receipt {
sender_fingerprint: sender_fingerprint.to_string(),
message_id: message_id.to_string(),
receipt_type: rt,
};
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
}
// ── Self-test (verifies full encrypt/decrypt cycle within WASM) ──
#[wasm_bindgen]
@@ -241,6 +281,7 @@ pub fn self_test() -> Result<String, JsValue> {
let encrypted_clone = encrypted.clone();
let _wire = WireMessage::KeyExchange {
id: uuid::Uuid::new_v4().to_string(),
sender_fingerprint: alice_pub.fingerprint.to_string(),
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
@@ -304,7 +345,8 @@ pub fn debug_bundle_info(identity: &mut WasmIdentity) -> Result<String, JsValue>
/// Decrypt a bincode WireMessage. `spk_secret_hex` is the signed pre-key secret
/// (stored in localStorage, generated during identity creation).
/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." }
/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64...", "message_id": "..." }
/// For Receipt messages: { "type": "receipt", "sender": "fp", "message_id": "...", "receipt_type": "delivered"|"read" }
#[wasm_bindgen]
pub fn decrypt_wire_message(
identity_hex_seed: &str,
@@ -324,6 +366,7 @@ pub fn decrypt_wire_message(
match wire {
WireMessage::KeyExchange {
id: msg_id,
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
@@ -357,9 +400,11 @@ pub fn decrypt_wire_message(
"text": String::from_utf8_lossy(&plain),
"new_session": true,
"session_data": session_b64,
"message_id": msg_id,
}).to_string())
}
WireMessage::Message {
id: msg_id,
sender_fingerprint,
ratchet_message,
} => {
@@ -384,6 +429,23 @@ pub fn decrypt_wire_message(
"text": String::from_utf8_lossy(&plain),
"new_session": false,
"session_data": session_b64,
"message_id": msg_id,
}).to_string())
}
WireMessage::Receipt {
sender_fingerprint,
message_id,
receipt_type,
} => {
let rt_str = match receipt_type {
ReceiptType::Delivered => "delivered",
ReceiptType::Read => "read",
};
Ok(serde_json::json!({
"type": "receipt",
"sender": sender_fingerprint,
"message_id": message_id,
"receipt_type": rt_str,
}).to_string())
}
}