Group chat with E2E encryption for both web and CLI clients
Server: - POST /v1/groups/create — create named group - POST /v1/groups/:name/join — join group - GET /v1/groups/:name — get group info + member list - GET /v1/groups — list all groups - POST /v1/groups/:name/send — fan-out encrypted messages to members - Groups stored in sled, members tracked by fingerprint Web client: - /gcreate <name> — create group - /gjoin <name> — join group - /g <name> — switch to group chat mode - /glist — list all groups - /dm — switch back to DM mode - Group messages encrypted per-member (ECDH + AES-GCM for each) - Group tag shown on received messages: "sender [groupname]" CLI TUI client: - Same commands: /gcreate, /gjoin, /g, /glist, /dm - Group messages encrypted per-member (X3DH + Double Ratchet for each) - Automatic X3DH key exchange with new group members on first message - Sessions established and persisted per-member Architecture: - Client-side fan-out encryption: message encrypted N times (once per member) - Server stores one copy per recipient in their message queue - Reuses existing 1:1 encryption — no new crypto primitives needed - Works for groups ≤ 50 members (per DESIGN.md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -295,7 +295,9 @@ async function pollMessages() {
|
||||
const aesKey = await fetchPeerKey(env.from);
|
||||
const ct = fromHex(env.ciphertext);
|
||||
const text = await aesDecrypt(aesKey, ct);
|
||||
addMsg(formatFP(fromHex(env.from)).slice(0, 19), text, false);
|
||||
const fromLabel = formatFP(fromHex(env.from)).slice(0, 19);
|
||||
const groupTag = env.group ? ' [' + env.group + ']' : '';
|
||||
addMsg(fromLabel + groupTag, text, false);
|
||||
continue;
|
||||
}
|
||||
} catch(e) { /* not JSON, might be CLI bincode */ }
|
||||
@@ -364,6 +366,8 @@ async function doRecover() {
|
||||
}
|
||||
}
|
||||
|
||||
let currentGroup = null; // if set, messages go to group
|
||||
|
||||
async function enterChat() {
|
||||
document.getElementById('setup').classList.remove('active');
|
||||
document.getElementById('chat').classList.add('active');
|
||||
@@ -373,18 +377,103 @@ async function enterChat() {
|
||||
await registerKey();
|
||||
addSys('Identity loaded: ' + myFingerprint);
|
||||
addSys('Key registered with server');
|
||||
addSys('Paste a peer fingerprint above and start chatting');
|
||||
addSys('Commands: /info, /clear, /quit');
|
||||
addSys('DM: paste peer fingerprint above');
|
||||
addSys('Groups: /gcreate <name> · /gjoin <name> · /g <name> · /glist');
|
||||
addSys('Other: /info · /clear · /dm (switch back to DM mode)');
|
||||
|
||||
// Restore saved peer
|
||||
const savedPeer = localStorage.getItem('wz-peer');
|
||||
if (savedPeer) $peerInput.value = savedPeer;
|
||||
|
||||
// Start polling
|
||||
pollTimer = setInterval(pollMessages, 2000);
|
||||
$input.focus();
|
||||
}
|
||||
|
||||
// ── Group helpers ──
|
||||
|
||||
async function groupCreate(name) {
|
||||
const resp = await fetch(SERVER + '/v1/groups/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, creator: normFP(myFingerprint) })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||
addSys('Group "' + name + '" created. Join with /gjoin ' + name);
|
||||
}
|
||||
|
||||
async function groupJoin(name) {
|
||||
const resp = await fetch(SERVER + '/v1/groups/' + name + '/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||
addSys('Joined group "' + name + '" (' + data.members + ' members)');
|
||||
}
|
||||
|
||||
async function groupSwitch(name) {
|
||||
const resp = await fetch(SERVER + '/v1/groups/' + name);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||
currentGroup = name;
|
||||
$peerInput.value = '#' + name;
|
||||
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
|
||||
}
|
||||
|
||||
async function groupList() {
|
||||
const resp = await fetch(SERVER + '/v1/groups');
|
||||
const data = await resp.json();
|
||||
if (!data.groups || data.groups.length === 0) { addSys('No groups'); return; }
|
||||
for (const g of data.groups) {
|
||||
addSys(' #' + g.name + ' (' + g.members + ' members)');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToGroup(groupName, text) {
|
||||
// Get member list
|
||||
const resp = await fetch(SERVER + '/v1/groups/' + groupName);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Error: ' + data.error); return; }
|
||||
|
||||
const myFP = normFP(myFingerprint);
|
||||
const members = data.members.filter(m => m !== myFP);
|
||||
if (members.length === 0) { addSys('No other members in group'); return; }
|
||||
|
||||
// Encrypt for each member
|
||||
const messages = [];
|
||||
for (const memberFP of members) {
|
||||
try {
|
||||
const aesKey = await fetchPeerKey(memberFP);
|
||||
const encrypted = await aesEncrypt(aesKey, text);
|
||||
const envelope = JSON.stringify({
|
||||
type: 'web',
|
||||
from: myFP,
|
||||
group: groupName,
|
||||
ciphertext: toHex(encrypted)
|
||||
});
|
||||
messages.push({
|
||||
to: memberFP,
|
||||
message: Array.from(new TextEncoder().encode(envelope))
|
||||
});
|
||||
} catch(e) {
|
||||
addSys('Failed to encrypt for ' + memberFP.slice(0,8) + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) return;
|
||||
|
||||
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: myFP, messages })
|
||||
});
|
||||
|
||||
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true);
|
||||
}
|
||||
|
||||
// ── Send handler ──
|
||||
|
||||
async function doSend() {
|
||||
const text = $input.value.trim();
|
||||
$input.value = '';
|
||||
@@ -395,9 +484,26 @@ async function doSend() {
|
||||
if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; }
|
||||
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
||||
if (text === '/quit') { window.close(); return; }
|
||||
if (text === '/glist') { await groupList(); return; }
|
||||
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
||||
|
||||
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
||||
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
||||
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
||||
|
||||
// Send to group or DM
|
||||
if (currentGroup) {
|
||||
try {
|
||||
await sendToGroup(currentGroup, text);
|
||||
} catch(e) {
|
||||
addSys('Group send failed: ' + e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DM
|
||||
const peer = $peerInput.value.trim();
|
||||
if (!peer) { addSys('Set a peer fingerprint first'); return; }
|
||||
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint or use /g <group>'); return; }
|
||||
localStorage.setItem('wz-peer', peer);
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user