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:
@@ -192,9 +192,50 @@ impl App {
|
||||
self.peer_fp = Some(fp);
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/gcreate ") {
|
||||
let name = text[9..].trim();
|
||||
self.group_create(name, client).await;
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/gjoin ") {
|
||||
let name = text[7..].trim();
|
||||
self.group_join(name, client).await;
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/g ") {
|
||||
let name = text[3..].trim().to_string();
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Switched to group #{}", name),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
});
|
||||
self.peer_fp = Some(format!("#{}", name));
|
||||
return;
|
||||
}
|
||||
if text == "/dm" {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Switched to DM mode. Use /peer <fp>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
});
|
||||
self.peer_fp = None;
|
||||
return;
|
||||
}
|
||||
if text == "/glist" {
|
||||
self.group_list(client).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Send message
|
||||
// Send message (group or DM)
|
||||
let peer = match &self.peer_fp {
|
||||
Some(p) if p.starts_with('#') => {
|
||||
// Group mode
|
||||
let group_name = p[1..].to_string();
|
||||
self.group_send(&group_name, &text, identity, db, client).await;
|
||||
return;
|
||||
}
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
self.add_message(ChatLine {
|
||||
@@ -328,6 +369,183 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn group_create(&self, name: &str, client: &ServerClient) {
|
||||
let url = format!("{}/v1/groups/create", client.base_url);
|
||||
match client.client.post(&url)
|
||||
.json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)}))
|
||||
.send().await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(err) = data.get("error") {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false });
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn group_join(&self, name: &str, client: &ServerClient) {
|
||||
let url = format!("{}/v1/groups/{}/join", client.base_url, name);
|
||||
match client.client.post(&url)
|
||||
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
|
||||
.send().await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(err) = data.get("error") {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false });
|
||||
} else {
|
||||
let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn group_list(&self, client: &ServerClient) {
|
||||
let url = format!("{}/v1/groups", client.base_url);
|
||||
match client.client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
|
||||
if groups.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false });
|
||||
} else {
|
||||
for g in groups {
|
||||
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn group_send(
|
||||
&self,
|
||||
group_name: &str,
|
||||
text: &str,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
// Get members
|
||||
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
|
||||
let group_data = match client.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; }
|
||||
},
|
||||
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; }
|
||||
};
|
||||
|
||||
let my_fp = normfp(&self.our_fp);
|
||||
let members: Vec<String> = group_data.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let our_pub = identity.public_identity();
|
||||
let mut wire_messages: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for member in &members {
|
||||
if *member == my_fp { continue; }
|
||||
let member_fp = match Fingerprint::from_hex(member) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut ratchet = db.load_session(&member_fp).ok().flatten();
|
||||
|
||||
let wire_msg = if let Some(ref mut state) = ratchet {
|
||||
match state.encrypt(text.as_bytes()) {
|
||||
Ok(encrypted) => {
|
||||
let _ = db.save_session(&member_fp, state);
|
||||
WireMessage::Message {
|
||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||
ratchet_message: encrypted,
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
// Need X3DH — fetch bundle
|
||||
let bundle = match client.fetch_bundle(member).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let x3dh_result = match x3dh::initiate(identity, &bundle) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||
match state.encrypt(text.as_bytes()) {
|
||||
Ok(encrypted) => {
|
||||
let _ = db.save_session(&member_fp, &state);
|
||||
WireMessage::KeyExchange {
|
||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||
ratchet_message: encrypted,
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = match bincode::serialize(&wire_msg) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
wire_messages.push(serde_json::json!({
|
||||
"to": member,
|
||||
"message": encoded,
|
||||
}));
|
||||
}
|
||||
|
||||
if wire_messages.is_empty() {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name);
|
||||
match client.client.post(&send_url)
|
||||
.json(&serde_json::json!({
|
||||
"from": my_fp,
|
||||
"messages": wire_messages,
|
||||
}))
|
||||
.send().await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: format!("{} [#{}]", &self.our_fp[..12], group_name),
|
||||
text: text.to_string(),
|
||||
is_system: false,
|
||||
is_self: true,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normfp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
/// Poll for incoming messages in the background.
|
||||
|
||||
Reference in New Issue
Block a user