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:
Siavash Sameni
2026-03-26 23:13:16 +04:00
parent 7b1e0bd162
commit f3e78c6cff
6 changed files with 530 additions and 8 deletions

View File

@@ -0,0 +1,193 @@
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/groups", get(list_groups))
.route("/groups/create", post(create_group))
.route("/groups/:name", get(get_group))
.route("/groups/:name/join", post(join_group))
.route("/groups/:name/send", post(send_to_group))
}
#[derive(Serialize, Deserialize, Clone)]
struct GroupInfo {
name: String,
creator: String,
members: Vec<String>, // fingerprints
}
#[derive(Deserialize)]
struct CreateRequest {
name: String,
creator: String, // fingerprint
}
#[derive(Deserialize)]
struct JoinRequest {
fingerprint: String,
}
/// A group message: the client sends one ciphertext per member.
/// Server fans out each entry to the respective member's message queue.
#[derive(Deserialize)]
struct GroupSendRequest {
from: String,
/// Each entry is an encrypted message destined for one member.
/// The client encrypts separately for each recipient.
messages: Vec<MemberMessage>,
}
#[derive(Deserialize)]
struct MemberMessage {
to: String, // member fingerprint
message: Vec<u8>, // encrypted payload (same format as 1:1 messages)
}
fn normalize_fp(fp: &str) -> String {
fp.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_lowercase()
}
fn load_group(db: &sled::Tree, name: &str) -> Option<GroupInfo> {
db.get(name.as_bytes())
.ok()
.flatten()
.and_then(|data| serde_json::from_slice(&data).ok())
}
fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
let data = serde_json::to_vec(group)?;
db.insert(group.name.as_bytes(), data)?;
Ok(())
}
async fn create_group(
State(state): State<AppState>,
Json(req): Json<CreateRequest>,
) -> AppResult<Json<serde_json::Value>> {
let name = req.name.trim().to_lowercase();
if name.is_empty() {
return Ok(Json(serde_json::json!({ "error": "name required" })));
}
if load_group(&state.db.groups, &name).is_some() {
return Ok(Json(serde_json::json!({ "error": "group already exists" })));
}
let creator = normalize_fp(&req.creator);
let group = GroupInfo {
name: name.clone(),
creator: creator.clone(),
members: vec![creator],
};
save_group(&state.db.groups, &group)?;
tracing::info!("Group '{}' created", name);
Ok(Json(serde_json::json!({ "ok": true, "name": name })))
}
async fn join_group(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<JoinRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = normalize_fp(&req.fingerprint);
let mut group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
if !group.members.contains(&fp) {
group.members.push(fp.clone());
save_group(&state.db.groups, &group)?;
tracing::info!("{} joined group '{}'", fp, name);
}
Ok(Json(serde_json::json!({ "ok": true, "members": group.members.len() })))
}
async fn get_group(
State(state): State<AppState>,
Path(name): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
match load_group(&state.db.groups, &name) {
Some(group) => Ok(Json(serde_json::json!({
"name": group.name,
"creator": group.creator,
"members": group.members,
"count": group.members.len(),
}))),
None => Ok(Json(serde_json::json!({ "error": "group not found" }))),
}
}
async fn list_groups(
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
let groups: Vec<serde_json::Value> = state
.db
.groups
.iter()
.filter_map(|item| {
item.ok().and_then(|(_, data)| {
serde_json::from_slice::<GroupInfo>(&data).ok().map(|g| {
serde_json::json!({
"name": g.name,
"members": g.members.len(),
})
})
})
})
.collect();
Ok(Json(serde_json::json!({ "groups": groups })))
}
/// Fan-out: client sends per-member encrypted messages, server puts each
/// in the respective member's queue. This reuses the existing message
/// queue infrastructure — group messages look like 1:1 messages to the
/// recipient, but with a group tag.
async fn send_to_group(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<GroupSendRequest>,
) -> AppResult<Json<serde_json::Value>> {
let group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
let from = normalize_fp(&req.from);
if !group.members.contains(&from) {
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
}
let mut delivered = 0;
for msg in &req.messages {
let to = normalize_fp(&msg.to);
if group.members.contains(&to) {
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
state.db.messages.insert(key.as_bytes(), msg.message.as_slice())?;
delivered += 1;
}
}
tracing::info!(
"Group '{}': {} sent {} messages to {} members",
name,
from,
delivered,
group.members.len()
);
Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered })))
}

View File

@@ -1,3 +1,4 @@
mod groups;
mod health;
mod keys;
mod messages;
@@ -12,6 +13,7 @@ pub fn router() -> Router<AppState> {
.merge(health::routes())
.merge(keys::routes())
.merge(messages::routes())
.merge(groups::routes())
}
/// Web UI router (served at root, outside /v1)

View File

@@ -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 {