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:
@@ -143,7 +143,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info } from '/wasm/warzone_wasm.js';
|
||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
|
||||
|
||||
const SERVER = window.location.origin;
|
||||
const $messages = document.getElementById('messages');
|
||||
@@ -160,9 +160,59 @@ let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.5';
|
||||
const VERSION = '0.0.6';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
let sentMsgReceipts = {}; // messageId -> { status: 'sent'|'delivered'|'read', el: DOM element }
|
||||
|
||||
function receiptIndicator(status) {
|
||||
switch (status) {
|
||||
case 'read': return '\u2713\u2713';
|
||||
case 'delivered': return '\u2713\u2713';
|
||||
case 'sent': default: return '\u2713';
|
||||
}
|
||||
}
|
||||
|
||||
function receiptColor(status) {
|
||||
switch (status) {
|
||||
case 'read': return '#67c7eb';
|
||||
case 'delivered': return '#ccc';
|
||||
case 'sent': default: return '#555';
|
||||
}
|
||||
}
|
||||
|
||||
function updateReceiptDisplay(messageId, status) {
|
||||
const entry = sentMsgReceipts[messageId];
|
||||
if (!entry) return;
|
||||
// Only upgrade status: sent -> delivered -> read
|
||||
const order = { sent: 0, delivered: 1, read: 2 };
|
||||
if ((order[status] || 0) <= (order[entry.status] || 0)) return;
|
||||
entry.status = status;
|
||||
if (entry.el) {
|
||||
entry.el.textContent = ' ' + receiptIndicator(status);
|
||||
entry.el.style.color = receiptColor(status);
|
||||
}
|
||||
}
|
||||
|
||||
function sendReceipt(toPeerFP, messageId, receiptType) {
|
||||
try {
|
||||
const receiptBytes = create_receipt(normFP(myFingerprint), messageId, receiptType);
|
||||
const fp = normFP(toPeerFP);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) }));
|
||||
} else {
|
||||
fetch(SERVER + '/v1/messages/send', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) })
|
||||
});
|
||||
}
|
||||
dbg('Sent', receiptType, 'receipt for', messageId, 'to', fp);
|
||||
} catch(e) {
|
||||
dbg('Failed to send receipt:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function dbg(...args) {
|
||||
if (DEBUG) console.log('[WZ]', ...args);
|
||||
}
|
||||
@@ -250,6 +300,13 @@ async function fetchPeerBundle(peerFP) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function generateMsgId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendEncrypted(peerFP, plaintext) {
|
||||
const fp = normFP(peerFP);
|
||||
dbg('sendEncrypted to:', fp, 'text length:', plaintext.length);
|
||||
@@ -257,23 +314,24 @@ async function sendEncrypted(peerFP, plaintext) {
|
||||
const bundleBytes = await fetchPeerBundle(fp);
|
||||
dbg('Got peer bundle, size:', bundleBytes.length);
|
||||
|
||||
const msgId = generateMsgId();
|
||||
let wireBytes;
|
||||
if (sessions[fp]) {
|
||||
dbg('Using existing session for', fp);
|
||||
try {
|
||||
const sess = WasmSession.restore(sessions[fp].data);
|
||||
wireBytes = sess.encrypt(wasmIdentity, plaintext);
|
||||
wireBytes = sess.encrypt_with_id(wasmIdentity, plaintext, msgId);
|
||||
sessions[fp].data = sess.save();
|
||||
} catch(e) {
|
||||
dbg('Existing session encrypt failed, creating new:', e.message);
|
||||
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
||||
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
||||
sessions[fp] = { data: sess.save() };
|
||||
}
|
||||
} else {
|
||||
dbg('New session (X3DH) for', fp);
|
||||
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
||||
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
||||
sessions[fp] = { data: sess.save() };
|
||||
}
|
||||
|
||||
@@ -281,7 +339,7 @@ async function sendEncrypted(peerFP, plaintext) {
|
||||
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||
));
|
||||
|
||||
dbg('Sending wire message, size:', wireBytes.length);
|
||||
dbg('Sending wire message, size:', wireBytes.length, 'id:', msgId);
|
||||
|
||||
// Prefer WebSocket, fall back to HTTP
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -303,6 +361,8 @@ async function sendEncrypted(peerFP, plaintext) {
|
||||
});
|
||||
dbg('Sent via HTTP (WS not connected)');
|
||||
}
|
||||
|
||||
return msgId;
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
@@ -339,11 +399,18 @@ function connectWebSocket() {
|
||||
async function handleIncomingMessage(bytes) {
|
||||
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
|
||||
|
||||
// First try: KeyExchange (no existing session needed)
|
||||
let decrypted = false;
|
||||
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
|
||||
try {
|
||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
||||
const result = JSON.parse(resultStr);
|
||||
|
||||
if (result.type === 'receipt') {
|
||||
dbg('Received', result.receipt_type, 'receipt for', result.message_id, 'from', result.sender);
|
||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||
return;
|
||||
}
|
||||
|
||||
// It was a KeyExchange
|
||||
dbg('Decrypted (KeyExchange) from:', result.sender);
|
||||
|
||||
const senderFP = normFP(result.sender);
|
||||
@@ -360,44 +427,50 @@ async function handleIncomingMessage(bytes) {
|
||||
} catch(e) {}
|
||||
|
||||
addMsg(fromLabel, result.text, false);
|
||||
decrypted = true;
|
||||
// Send delivery receipt
|
||||
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||
return;
|
||||
} catch(e) {
|
||||
dbg('KeyExchange failed:', e.message || e);
|
||||
dbg('KeyExchange/Receipt parse failed:', e.message || e);
|
||||
}
|
||||
|
||||
// Second try: existing sessions
|
||||
if (!decrypted) {
|
||||
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
||||
try {
|
||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
|
||||
const result = JSON.parse(resultStr);
|
||||
dbg('Decrypted with session', senderFP);
|
||||
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
||||
try {
|
||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
|
||||
const result = JSON.parse(resultStr);
|
||||
|
||||
sessions[senderFP] = { data: result.session_data };
|
||||
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||
));
|
||||
|
||||
let fromLabel = result.sender.slice(0, 19);
|
||||
try {
|
||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
||||
const ad = await ar.json();
|
||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||
} catch(e2) {}
|
||||
|
||||
addMsg(fromLabel, result.text, false);
|
||||
decrypted = true;
|
||||
break;
|
||||
} catch(e2) {
|
||||
dbg('Session', senderFP, 'failed:', e2.message || e2);
|
||||
if (result.type === 'receipt') {
|
||||
dbg('Received', result.receipt_type, 'receipt for', result.message_id);
|
||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||
return;
|
||||
}
|
||||
|
||||
dbg('Decrypted with session', senderFP);
|
||||
|
||||
sessions[senderFP] = { data: result.session_data };
|
||||
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||
));
|
||||
|
||||
let fromLabel = result.sender.slice(0, 19);
|
||||
try {
|
||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
||||
const ad = await ar.json();
|
||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||
} catch(e2) {}
|
||||
|
||||
addMsg(fromLabel, result.text, false);
|
||||
// Send delivery receipt
|
||||
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||
return;
|
||||
} catch(e2) {
|
||||
dbg('Session', senderFP, 'failed:', e2.message || e2);
|
||||
}
|
||||
}
|
||||
|
||||
if (!decrypted) {
|
||||
dbg('ALL decrypt attempts failed');
|
||||
addSys('[message could not be decrypted]');
|
||||
}
|
||||
dbg('ALL decrypt attempts failed');
|
||||
addSys('[message could not be decrypted]');
|
||||
}
|
||||
|
||||
// Load saved sessions
|
||||
@@ -431,14 +504,25 @@ function peerColor(name) {
|
||||
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
|
||||
}
|
||||
|
||||
function addMsg(from, text, isSelf) {
|
||||
function addMsg(from, text, isSelf, messageId) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const color = isSelf ? '#4ade80' : peerColor(from);
|
||||
const lock = isSelf ? '' : '<span class="lock">🔒 </span>';
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text);
|
||||
let receiptHtml = '';
|
||||
if (isSelf && messageId) {
|
||||
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
|
||||
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
|
||||
}
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
|
||||
$messages.appendChild(d);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
// Store reference to the receipt span so we can update it later
|
||||
if (isSelf && messageId) {
|
||||
const receiptEl = d.querySelector('.receipt');
|
||||
if (!sentMsgReceipts[messageId]) sentMsgReceipts[messageId] = { status: 'sent', el: null };
|
||||
sentMsgReceipts[messageId].el = receiptEl;
|
||||
}
|
||||
}
|
||||
|
||||
function addSys(text) {
|
||||
@@ -580,7 +664,7 @@ async function sendToGroup(groupName, text) {
|
||||
body: JSON.stringify({ from: myFP, messages })
|
||||
});
|
||||
|
||||
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true);
|
||||
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true, null);
|
||||
}
|
||||
|
||||
// ── Send handler ──
|
||||
@@ -677,8 +761,9 @@ async function doSend() {
|
||||
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
||||
|
||||
try {
|
||||
await sendEncrypted(peer, text);
|
||||
addMsg(myFingerprint.slice(0, 19), text, true);
|
||||
const msgId = await sendEncrypted(peer, text);
|
||||
sentMsgReceipts[msgId] = { status: 'sent', el: null };
|
||||
addMsg(myFingerprint.slice(0, 19), text, true, msgId);
|
||||
} catch(e) {
|
||||
addSys('Send failed: ' + e.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user