First commit in rust

This commit is contained in:
2026-02-19 13:51:07 +08:00
commit c37ca9ba52
73 changed files with 9737 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(git -C /c/Users/btaylor/Git/psg-backchannel log --oneline -10)",
"WebFetch(domain:crates.io)",
"Bash(cargo check:*)",
"Bash(echo:*)",
"Bash(~/.cargo/bin/cargo check:*)"
]
}
}

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
/target/
**/*.db
**/*.db-shm
**/*.db-wal
.env
*.pem
*.key
*.crt
*.p12
/backchannel-server/backchannel.db
/backchannel-web/node_modules/
/backchannel-web/.vite/

3532
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[workspace]
resolver = "2"
members = [
"backchannel-common",
"backchannel-server",
"backchannel-client",
]
[workspace.dependencies]
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# IDs / time
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "1"
anyhow = "1"
# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Encoding
base64 = "0.22"
# Crypto
rand = "0.8"
argon2 = "0.5"
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
# Async runtime
tokio = { version = "1", features = ["full"] }
# WebSocket
tokio-tungstenite = "0.21"

View File

@@ -0,0 +1,33 @@
[package]
name = "backchannel-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "backchannel"
path = "src/main.rs"
[dependencies]
backchannel-common = { path = "../backchannel-common" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
base64 = { workspace = true }
rand = { workspace = true }
ed25519-dalek = { workspace = true }
x25519-dalek = { workspace = true }
chacha20poly1305 = { workspace = true }
hkdf = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
ratatui = "0.26"
crossterm = "0.27"

View File

@@ -0,0 +1,24 @@
/// Runtime configuration for the BackChannel client.
pub struct ClientConfig {
/// WebSocket server URL, e.g. `ws://127.0.0.1:7777` or `wss://server:7777`.
pub server_url: String,
/// Path to the keystore JSON file (holds the Ed25519 identity keypair).
pub keystore_path: String,
}
impl ClientConfig {
pub fn from_env() -> Self {
let server_url = std::env::var("BC_SERVER_URL")
.unwrap_or_else(|_| "ws://127.0.0.1:7777".into());
let keystore_path = std::env::var("BC_KEYSTORE").unwrap_or_else(|_| {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".into());
format!("{}/.backchannel/keystore.json", home)
});
Self { server_url, keystore_path }
}
}

View File

@@ -0,0 +1,36 @@
use backchannel_common::crypto::{aead, ecdh};
use x25519_dalek::EphemeralSecret;
use crate::error::Result;
/// Generate an ephemeral X25519 keypair to initiate a DM key exchange.
///
/// Returns the `EphemeralSecret` (store in `Keystore::pending_ecdh`) and the
/// base64-encoded public key to send to the server.
pub fn generate_exchange() -> (EphemeralSecret, String) {
let (secret, pubkey) = ecdh::generate_ephemeral();
let pubkey_b64 = ecdh::pubkey_to_b64(&pubkey);
(secret, pubkey_b64)
}
/// Complete a key exchange given our ephemeral secret and the peer's base64
/// public key. Returns the 32-byte derived DM session key.
///
/// The `EphemeralSecret` is consumed here — it cannot be reused.
pub fn complete_exchange(our_secret: EphemeralSecret, their_pubkey_b64: &str) -> Result<[u8; 32]> {
let their_pubkey = ecdh::b64_to_pubkey(their_pubkey_b64)?;
let raw_secret = ecdh::diffie_hellman(our_secret, &their_pubkey);
Ok(aead::derive_dm_key(&raw_secret))
}
/// Encrypt a plaintext DM using a derived session key.
/// Returns `(ciphertext_b64, nonce_b64)`.
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<(String, String)> {
aead::encrypt(key, plaintext.as_bytes()).map_err(Into::into)
}
/// Decrypt a received DM ciphertext using a derived session key.
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<String> {
let bytes = aead::decrypt(key, nonce_b64, ciphertext_b64)?;
Ok(String::from_utf8(bytes)?)
}

View File

@@ -0,0 +1 @@
pub mod dm;

View File

@@ -0,0 +1,30 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ClientError>;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("not authenticated")]
NotAuthenticated,
#[error("server error {code}: {message}")]
Server { code: u16, message: String },
#[error("no DM key for peer — initiate key exchange first")]
NoDmKey,
#[error("crypto error: {0}")]
Crypto(#[from] backchannel_common::BackchannelError),
#[error("serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("UTF-8 error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("{0}")]
Other(String),
}

View File

@@ -0,0 +1,91 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use x25519_dalek::EphemeralSecret;
use backchannel_common::crypto::identity;
use crate::error::{ClientError, Result};
/// On-disk representation of the keystore.
#[derive(Serialize, Deserialize)]
struct KeystoreFile {
/// Base64-encoded Ed25519 signing key (32 bytes).
signing_key_b64: String,
}
/// Holds cryptographic material for the local client.
pub struct Keystore {
pub signing_key: SigningKey,
pub verifying_key: VerifyingKey,
/// Derived DM session keys cached in memory: peer_user_id → 32-byte key.
/// Populated after completing an X25519 key exchange.
pub dm_keys: HashMap<Uuid, [u8; 32]>,
/// Ephemeral secrets awaiting the remote party's public key.
/// Keyed by the peer's user UUID. Consumed on exchange completion.
pub pending_ecdh: HashMap<Uuid, EphemeralSecret>,
}
impl Keystore {
/// Load an existing keystore from `path`, or generate a fresh one and save it.
pub fn load_or_create(path: &str) -> Result<Self> {
if Path::new(path).exists() {
let contents = fs::read_to_string(path)?;
let file: KeystoreFile =
serde_json::from_str(&contents).map_err(ClientError::Json)?;
let signing_key = identity::b64_to_signing_key(&file.signing_key_b64)?;
let verifying_key = signing_key.verifying_key();
tracing::debug!("Loaded keystore from {}", path);
Ok(Self::from_signing_key(signing_key, verifying_key))
} else {
let (signing_key, verifying_key) = identity::generate_keypair();
let file = KeystoreFile {
signing_key_b64: identity::signing_key_to_b64(&signing_key),
};
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(&file)?)?;
tracing::info!("Generated new identity keypair, saved to {}", path);
Ok(Self::from_signing_key(signing_key, verifying_key))
}
}
fn from_signing_key(signing_key: SigningKey, verifying_key: VerifyingKey) -> Self {
Self {
signing_key,
verifying_key,
dm_keys: HashMap::new(),
pending_ecdh: HashMap::new(),
}
}
/// Base64-encoded Ed25519 public key, suitable for sending to the server.
pub fn identity_pubkey_b64(&self) -> String {
identity::pubkey_to_b64(&self.verifying_key)
}
/// Store an `EphemeralSecret` while waiting for the remote peer's response.
pub fn store_pending_ecdh(&mut self, peer_id: Uuid, secret: EphemeralSecret) {
self.pending_ecdh.insert(peer_id, secret);
}
/// Retrieve and remove a pending ephemeral secret (consumed by ECDH).
pub fn take_pending_ecdh(&mut self, peer_id: Uuid) -> Option<EphemeralSecret> {
self.pending_ecdh.remove(&peer_id)
}
pub fn cache_dm_key(&mut self, peer_id: Uuid, key: [u8; 32]) {
self.dm_keys.insert(peer_id, key);
}
pub fn get_dm_key(&self, peer_id: &Uuid) -> Option<&[u8; 32]> {
self.dm_keys.get(peer_id)
}
}

View File

@@ -0,0 +1,53 @@
mod config;
mod crypto;
mod error;
mod keystore;
mod net;
mod tui;
use std::io;
use anyhow::Result;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "backchannel=info".into()),
)
.with_writer(std::io::stderr) // keep TUI output clean
.init();
let cfg = config::ClientConfig::from_env();
let keystore = keystore::Keystore::load_or_create(&cfg.keystore_path)?;
tracing::info!("Connecting to {}", cfg.server_url);
let (net_tx, mut server_rx) = net::connection::connect(&cfg.server_url).await?;
let app = &mut tui::app::App::new(net_tx, keystore);
// ── Terminal setup ────────────────────────────────────────────────────
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// ── Main event loop ───────────────────────────────────────────────────
let result = tui::events::run(app, &mut terminal, &mut server_rx).await;
// ── Cleanup (always run, even on error) ───────────────────────────────
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}

View File

@@ -0,0 +1,66 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
use backchannel_common::protocol::{ClientMessage, ServerMessage};
/// Connect to the BackChannel server and return a pair of mpsc channels:
///
/// - `UnboundedSender<ClientMessage>` — send messages **to** the server
/// - `UnboundedReceiver<ServerMessage>` — receive messages **from** the server
///
/// Two background tasks handle the actual WebSocket I/O so the caller never
/// touches the raw stream.
pub async fn connect(
server_url: &str,
) -> Result<(
mpsc::UnboundedSender<ClientMessage>,
mpsc::UnboundedReceiver<ServerMessage>,
)> {
let (ws_stream, _) = connect_async(server_url)
.await
.with_context(|| format!("Failed to connect to {}", server_url))?;
let (mut ws_sink, mut ws_source) = ws_stream.split();
let (client_tx, mut client_rx) = mpsc::unbounded_channel::<ClientMessage>();
let (server_tx, server_rx) = mpsc::unbounded_channel::<ServerMessage>();
// Outbound: client_rx → WS sink
tokio::spawn(async move {
while let Some(msg) = client_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
if ws_sink.send(WsMessage::Text(json)).await.is_err() {
break;
}
}
Err(e) => tracing::error!("Failed to serialize outbound message: {}", e),
}
}
});
// Inbound: WS source → server_tx
tokio::spawn(async move {
while let Some(item) = ws_source.next().await {
match item {
Ok(WsMessage::Text(text)) => {
match serde_json::from_str::<ServerMessage>(&text) {
Ok(msg) => {
if server_tx.send(msg).is_err() {
break; // receiver dropped; shut down
}
}
Err(e) => tracing::warn!("Failed to parse server message: {}", e),
}
}
Ok(WsMessage::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
tracing::info!("Connection to server closed");
});
Ok((client_tx, server_rx))
}

View File

@@ -0,0 +1 @@
pub mod connection;

View File

@@ -0,0 +1,351 @@
use std::collections::{HashMap, VecDeque};
use backchannel_common::protocol::{ClientMessage, ServerMessage};
use chrono::Local;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::keystore::Keystore;
const MAX_MESSAGES: usize = 500;
/// What the TUI is currently displaying.
#[derive(Debug, Clone, PartialEq)]
pub enum View {
Login,
Channel,
Dm,
}
/// Which input field is focused on the login screen.
#[derive(Debug, Clone, PartialEq)]
pub enum LoginField {
Username,
Password,
}
/// A single chat message for display.
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub author: String,
pub content: String,
pub timestamp: String,
}
/// All mutable state for the TUI application.
pub struct App {
// ── Navigation ────────────────────────────────────────────────────────
pub view: View,
pub should_quit: bool,
// ── Login screen ─────────────────────────────────────────────────────
pub login_username: String,
pub login_password: String,
pub login_field: LoginField,
pub login_register: bool, // toggle between Login and Register mode
pub status_msg: Option<String>,
// ── Auth state ────────────────────────────────────────────────────────
pub my_user_id: Option<Uuid>,
pub my_username: Option<String>,
// ── Channel state ─────────────────────────────────────────────────────
pub channels: Vec<(Uuid, String)>, // (id, name)
pub selected_channel: usize,
pub channel_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
// ── DM state ──────────────────────────────────────────────────────────
pub dm_peer_id: Option<Uuid>,
pub dm_peer_name: Option<String>,
pub dm_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
// ── Text input (shared between channel + DM views) ────────────────────
pub input: String,
// ── Online users ──────────────────────────────────────────────────────
pub online_users: Vec<(Uuid, String)>,
// ── Network ───────────────────────────────────────────────────────────
/// Send channel messages to the server connection task.
pub net_tx: mpsc::UnboundedSender<ClientMessage>,
/// Keystore for crypto operations.
pub keystore: Keystore,
}
impl App {
pub fn new(net_tx: mpsc::UnboundedSender<ClientMessage>, keystore: Keystore) -> Self {
Self {
view: View::Login,
should_quit: false,
login_username: String::new(),
login_password: String::new(),
login_field: LoginField::Username,
login_register: false,
status_msg: None,
my_user_id: None,
my_username: None,
channels: Vec::new(),
selected_channel: 0,
channel_messages: HashMap::new(),
dm_peer_id: None,
dm_peer_name: None,
dm_messages: HashMap::new(),
input: String::new(),
online_users: Vec::new(),
net_tx,
keystore,
}
}
// ── Network message handling ──────────────────────────────────────────
pub fn handle_server_message(&mut self, msg: ServerMessage) {
match msg {
ServerMessage::AuthSuccess { user_id, username, .. } => {
self.my_user_id = Some(user_id);
self.my_username = Some(username.clone());
self.status_msg = None;
self.view = View::Channel;
self.status_msg = Some(format!("Logged in as {}", username));
}
ServerMessage::AuthError { reason } => {
self.status_msg = Some(format!("Auth failed: {}", reason));
}
ServerMessage::ChannelMessage {
channel_id,
author_username,
content,
timestamp,
..
} => {
let entry = self
.channel_messages
.entry(channel_id)
.or_default();
entry.push_back(ChatMessage {
author: author_username,
content,
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
});
if entry.len() > MAX_MESSAGES {
entry.pop_front();
}
}
ServerMessage::ChannelCreated { channel_id, name, .. } => {
if !self.channels.iter().any(|(id, _)| *id == channel_id) {
self.channels.push((channel_id, name));
}
}
ServerMessage::ChannelDeleted { channel_id } => {
self.channels.retain(|(id, _)| *id != channel_id);
self.channel_messages.remove(&channel_id);
}
ServerMessage::DirectMessage {
sender_id,
sender_username,
ciphertext,
nonce,
timestamp,
..
} => {
// Determine the peer UUID (the other party in the conversation).
let peer_id = if Some(sender_id) == self.my_user_id {
self.dm_peer_id.unwrap_or(sender_id)
} else {
sender_id
};
// Try to decrypt; fall back to a placeholder if the key is missing.
let plaintext = self.keystore
.get_dm_key(&peer_id)
.and_then(|key| {
crate::crypto::dm::decrypt(key, &nonce, &ciphertext).ok()
})
.unwrap_or_else(|| "[encrypted]".into());
let entry = self.dm_messages.entry(peer_id).or_default();
entry.push_back(ChatMessage {
author: sender_username,
content: plaintext,
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
});
if entry.len() > MAX_MESSAGES {
entry.pop_front();
}
}
ServerMessage::DmKeyExchangeRequest {
initiator_id,
initiator_username,
sender_ephemeral_pubkey,
} => {
// Bob receives this — generate our ephemeral pair and respond.
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
// Derive the shared key now (Bob has both keys).
if let Ok(key) = crate::crypto::dm::complete_exchange(secret, &sender_ephemeral_pubkey) {
self.keystore.cache_dm_key(initiator_id, key);
}
let _ = self.net_tx.send(ClientMessage::AcceptDmKeyExchange {
initiator_id,
recipient_ephemeral_pubkey: our_pubkey_b64,
});
self.status_msg = Some(format!("{} wants to DM you", initiator_username));
}
ServerMessage::DmKeyExchangeResponse {
recipient_id,
recipient_ephemeral_pubkey,
..
} => {
// Alice receives this — complete the exchange.
if let Some(secret) = self.keystore.take_pending_ecdh(recipient_id) {
if let Ok(key) =
crate::crypto::dm::complete_exchange(secret, &recipient_ephemeral_pubkey)
{
self.keystore.cache_dm_key(recipient_id, key);
self.status_msg = Some("DM key exchange complete — you can now send encrypted messages".into());
}
}
}
ServerMessage::UserOnline { user_id, username } => {
if !self.online_users.iter().any(|(id, _)| *id == user_id) {
self.online_users.push((user_id, username));
}
}
ServerMessage::UserOffline { user_id, .. } => {
self.online_users.retain(|(id, _)| *id != user_id);
}
ServerMessage::Error { code, message } => {
self.status_msg = Some(format!("Error {}: {}", code, message));
}
// Other messages (history, role events, etc.) are acknowledged but
// not yet wired into the TUI — they will be handled in future iterations.
_ => {}
}
}
// ── Input helpers ─────────────────────────────────────────────────────
/// Submit the current channel text input.
pub fn submit_channel_message(&mut self) {
let content = self.input.trim().to_string();
if content.is_empty() {
return;
}
if let Some((channel_id, _)) = self.channels.get(self.selected_channel) {
let _ = self.net_tx.send(ClientMessage::SendChannelMessage {
channel_id: *channel_id,
content,
});
}
self.input.clear();
}
/// Submit the current DM text input (encrypted).
pub fn submit_dm_message(&mut self) {
let content = self.input.trim().to_string();
if content.is_empty() {
return;
}
let Some(peer_id) = self.dm_peer_id else { return };
match self.keystore.get_dm_key(&peer_id) {
Some(key) => {
let key = *key;
match crate::crypto::dm::encrypt(&key, &content) {
Ok((ciphertext, nonce)) => {
let _ = self.net_tx.send(ClientMessage::SendDm {
recipient_id: peer_id,
ciphertext,
nonce,
});
}
Err(e) => {
self.status_msg = Some(format!("Encryption error: {}", e));
}
}
}
None => {
self.status_msg = Some(
"No DM key — initiate key exchange with /dm @user first".into(),
);
}
}
self.input.clear();
}
/// Initiate a DM key exchange with a peer by their UUID.
pub fn initiate_dm(&mut self, peer_id: Uuid, peer_name: String) {
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
self.keystore.store_pending_ecdh(peer_id, secret);
let _ = self.net_tx.send(ClientMessage::InitDmKeyExchange {
recipient_id: peer_id,
sender_ephemeral_pubkey: our_pubkey_b64,
});
self.dm_peer_id = Some(peer_id);
self.dm_peer_name = Some(peer_name);
self.view = View::Dm;
self.status_msg = Some("Key exchange initiated — waiting for peer...".into());
}
/// Submit the login/register form.
pub fn submit_login(&mut self) {
let username = self.login_username.trim().to_string();
let password = self.login_password.trim().to_string();
if username.is_empty() || password.is_empty() {
self.status_msg = Some("Username and password are required".into());
return;
}
let identity_pubkey = self.keystore.identity_pubkey_b64();
let msg = if self.login_register {
ClientMessage::Register { username, password, identity_pubkey }
} else {
ClientMessage::Login { username, password, identity_pubkey }
};
let _ = self.net_tx.send(msg);
self.status_msg = Some("Connecting...".into());
}
/// Current channel (Uuid, name), if any channel is selected.
pub fn current_channel(&self) -> Option<&(Uuid, String)> {
self.channels.get(self.selected_channel)
}
/// Messages for the currently viewed channel.
pub fn current_channel_messages(&self) -> Option<&VecDeque<ChatMessage>> {
self.current_channel()
.and_then(|(id, _)| self.channel_messages.get(id))
}
/// Messages for the current DM peer.
pub fn current_dm_messages(&self) -> Option<&VecDeque<ChatMessage>> {
self.dm_peer_id
.and_then(|id| self.dm_messages.get(&id))
}
}

View File

@@ -0,0 +1,116 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::Stdout;
use std::time::Duration;
use crate::tui::app::{App, LoginField, View};
/// The main event loop. Runs until `app.should_quit` is set.
///
/// Every ~50 ms we:
/// 1. Drain any pending server messages from `server_rx`.
/// 2. Poll for a crossterm keyboard event.
/// 3. Redraw the terminal.
pub async fn run(
app: &mut App,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
server_rx: &mut tokio::sync::mpsc::UnboundedReceiver<backchannel_common::protocol::ServerMessage>,
) -> Result<()> {
loop {
// Drain server messages (non-blocking).
while let Ok(msg) = server_rx.try_recv() {
app.handle_server_message(msg);
}
// Draw.
terminal.draw(|f| crate::tui::views::draw(f, app))?;
// Poll keyboard events with a short timeout so server messages stay responsive.
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
handle_key(app, key.code, key.modifiers);
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
// Ctrl-C or Ctrl-Q always quit.
if modifiers.contains(KeyModifiers::CONTROL)
&& (code == KeyCode::Char('c') || code == KeyCode::Char('q'))
{
app.should_quit = true;
return;
}
match app.view {
View::Login => handle_login_key(app, code),
View::Channel => handle_channel_key(app, code),
View::Dm => handle_dm_key(app, code),
}
}
fn handle_login_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Tab => {
app.login_field = match app.login_field {
LoginField::Username => LoginField::Password,
LoginField::Password => LoginField::Username,
};
}
KeyCode::Enter => app.submit_login(),
KeyCode::Char('t') if app.login_username.is_empty() && app.login_password.is_empty() => {
// Toggle register/login mode only when both fields are empty
app.login_register = !app.login_register;
}
KeyCode::Char(c) => match app.login_field {
LoginField::Username => app.login_username.push(c),
LoginField::Password => app.login_password.push(c),
},
KeyCode::Backspace => match app.login_field {
LoginField::Username => { app.login_username.pop(); }
LoginField::Password => { app.login_password.pop(); }
},
_ => {}
}
}
fn handle_channel_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Enter => app.submit_channel_message(),
KeyCode::Char(c) => app.input.push(c),
KeyCode::Backspace => { app.input.pop(); }
// Channel navigation
KeyCode::Up => {
if app.selected_channel > 0 {
app.selected_channel -= 1;
}
}
KeyCode::Down => {
if app.selected_channel + 1 < app.channels.len() {
app.selected_channel += 1;
}
}
KeyCode::Esc => app.status_msg = None,
_ => {}
}
}
fn handle_dm_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Enter => app.submit_dm_message(),
KeyCode::Char(c) => app.input.push(c),
KeyCode::Backspace => { app.input.pop(); }
KeyCode::Esc => {
app.view = View::Channel;
app.input.clear();
}
_ => {}
}
}

View File

@@ -0,0 +1,3 @@
pub mod app;
pub mod events;
pub mod views;

View File

@@ -0,0 +1,147 @@
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::tui::app::App;
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
// Split into sidebar | main column
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(22), Constraint::Min(0)])
.split(area);
draw_sidebar(frame, app, columns[0]);
draw_main(frame, app, columns[1]);
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(6)])
.split(area);
// ── Channel list ────────────────────────────────────────────────────
let items: Vec<ListItem> = app
.channels
.iter()
.enumerate()
.map(|(i, (_, name))| {
let style = if i == app.selected_channel {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(format!("# {}", name)).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_channel));
let channels = List::new(items)
.block(
Block::default()
.title(" Channels ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan)),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
frame.render_stateful_widget(channels, rows[0], &mut state);
// ── Online users ─────────────────────────────────────────────────────
let user_items: Vec<ListItem> = app
.online_users
.iter()
.map(|(_, name)| ListItem::new(format!("{}", name)))
.collect();
let online = List::new(user_items).block(
Block::default()
.title(" Online ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Green)),
);
frame.render_widget(online, rows[1]);
}
fn draw_main(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // channel title bar
Constraint::Min(0), // messages
Constraint::Length(3), // input box
Constraint::Length(1), // status bar
])
.split(area);
// ── Title bar ────────────────────────────────────────────────────────
let title = app
.current_channel()
.map(|(_, name)| format!(" # {} ", name))
.unwrap_or_else(|| " No channel selected ".into());
let title_bar = Paragraph::new(title)
.style(Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD));
frame.render_widget(title_bar, rows[0]);
// ── Messages ─────────────────────────────────────────────────────────
let msgs: Vec<ListItem> = app
.current_channel_messages()
.map(|deque| {
deque
.iter()
.map(|m| {
ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", m.timestamp),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{}: ", m.author),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(m.content.clone()),
]))
})
.collect()
})
.unwrap_or_default();
let message_count = msgs.len();
let mut list_state = ListState::default();
if message_count > 0 {
list_state.select(Some(message_count - 1));
}
let messages = List::new(msgs)
.block(Block::default().borders(Borders::LEFT))
.highlight_style(Style::default());
frame.render_stateful_widget(messages, rows[1], &mut list_state);
// ── Input box ─────────────────────────────────────────────────────────
let input = Paragraph::new(app.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Message (Enter to send) "),
);
frame.render_widget(input, rows[2]);
// ── Status bar ────────────────────────────────────────────────────────
let status_text = app
.status_msg
.as_deref()
.unwrap_or("Ctrl-Q: quit | ↑↓: channels | Esc: dismiss");
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, rows[3]);
}

View File

@@ -0,0 +1,86 @@
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::tui::app::App;
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // messages
Constraint::Length(3), // input
Constraint::Length(1), // status
])
.split(area);
// ── Title bar ─────────────────────────────────────────────────────────
let peer_name = app
.dm_peer_name
.as_deref()
.unwrap_or("Unknown");
let title = Paragraph::new(format!(" DM: {} (E2EE) ", peer_name))
.style(Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD));
frame.render_widget(title, rows[0]);
// ── Messages ──────────────────────────────────────────────────────────
let msgs: Vec<ListItem> = app
.current_dm_messages()
.map(|deque| {
deque
.iter()
.map(|m| {
let is_self = app.my_username.as_deref() == Some(m.author.as_str());
let author_style = if is_self {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", m.timestamp),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!("{}: ", m.author), author_style),
Span::raw(m.content.clone()),
]))
})
.collect()
})
.unwrap_or_default();
let msg_count = msgs.len();
let mut state = ListState::default();
if msg_count > 0 {
state.select(Some(msg_count - 1));
}
let messages = List::new(msgs).block(Block::default().borders(Borders::LEFT));
frame.render_stateful_widget(messages, rows[1], &mut state);
// ── Input box ─────────────────────────────────────────────────────────
let input = Paragraph::new(app.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Encrypted message (Enter: send Esc: back) "),
);
frame.render_widget(input, rows[2]);
// ── Status bar ────────────────────────────────────────────────────────
let status_text = app
.status_msg
.as_deref()
.unwrap_or("🔒 End-to-end encrypted");
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, rows[3]);
}

View File

@@ -0,0 +1,98 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::tui::app::{App, LoginField};
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
// Centre a 50×12 box on screen.
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30),
Constraint::Length(14),
Constraint::Min(0),
])
.split(area);
let h = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(v[1]);
let box_area = h[1];
let mode_label = if app.login_register { "Register" } else { "Login" };
let outer = Block::default()
.title(format!(" BackChannel — {} ", mode_label))
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(outer, box_area);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // padding
Constraint::Length(3), // username
Constraint::Length(3), // password
Constraint::Length(1), // padding
Constraint::Length(1), // hint
Constraint::Length(1), // status
])
.margin(1)
.split(box_area);
// Username field
let username_style = if app.login_field == LoginField::Username {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let username_widget = Paragraph::new(app.login_username.as_str())
.block(Block::default().borders(Borders::ALL).title(" Username "))
.style(username_style);
frame.render_widget(username_widget, inner[1]);
// Password field (mask with asterisks)
let password_style = if app.login_field == LoginField::Password {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let masked: String = "*".repeat(app.login_password.len());
let password_widget = Paragraph::new(masked.as_str())
.block(Block::default().borders(Borders::ALL).title(" Password "))
.style(password_style);
frame.render_widget(password_widget, inner[2]);
// Hints
let hint = Paragraph::new(Line::from(vec![
Span::raw("Tab: switch field | Enter: submit | "),
Span::styled(
if app.login_register { "T: switch to Login" } else { "T: switch to Register" },
Style::default().fg(Color::DarkGray),
),
]))
.alignment(Alignment::Center);
frame.render_widget(hint, inner[4]);
// Status / error message
if let Some(msg) = &app.status_msg {
let status = Paragraph::new(msg.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center);
frame.render_widget(status, inner[5]);
}
}

View File

@@ -0,0 +1,16 @@
pub mod channel;
pub mod dm;
pub mod login;
use ratatui::Frame;
use crate::tui::app::{App, View};
/// Top-level draw dispatcher — renders the correct view based on `app.view`.
pub fn draw(frame: &mut Frame, app: &App) {
match app.view {
View::Login => login::draw(frame, app),
View::Channel => channel::draw(frame, app),
View::Dm => dm::draw(frame, app),
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "backchannel-common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
base64 = { workspace = true }
rand = { workspace = true }
ed25519-dalek = { workspace = true }
x25519-dalek = { workspace = true }
chacha20poly1305 = { workspace = true }
hkdf = { workspace = true }
sha2 = { workspace = true }

View File

@@ -0,0 +1,59 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use hkdf::Hkdf;
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::Sha256;
use crate::error::{BackchannelError, Result};
const HKDF_INFO: &[u8] = b"backchannel-dm-v1";
const HKDF_SALT: &[u8] = b"backchannel-salt-v1";
/// Derive a 32-byte ChaCha20-Poly1305 key from a raw X25519 shared secret
/// using HKDF-SHA256. The raw DH output should never be used directly.
pub fn derive_dm_key(raw_secret: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), raw_secret);
let mut okm = [0u8; 32];
hk.expand(HKDF_INFO, &mut okm)
.expect("32-byte output always fits HKDF-SHA256");
okm
}
/// Encrypt `plaintext` with a 32-byte key.
///
/// Returns `(ciphertext_b64, nonce_b64)`. A fresh random nonce is generated
/// for every call — callers must transmit both values to the recipient.
pub fn encrypt(key_bytes: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
let key = Key::from_slice(key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| BackchannelError::Crypto("encryption failed".into()))?;
Ok((B64.encode(&ciphertext), B64.encode(nonce_bytes)))
}
/// Decrypt `ciphertext_b64` using `key_bytes` and `nonce_b64`.
pub fn decrypt(key_bytes: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
let key = Key::from_slice(key_bytes);
let cipher = ChaCha20Poly1305::new(key);
let nonce_bytes = B64.decode(nonce_b64)?;
let nonce_arr: [u8; 12] = nonce_bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
let nonce = Nonce::from(nonce_arr);
let ciphertext = B64.decode(ciphertext_b64)?;
let plaintext = cipher.decrypt(&nonce, ciphertext.as_ref())?;
Ok(plaintext)
}

View File

@@ -0,0 +1,37 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use rand::rngs::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::error::{BackchannelError, Result};
/// Generate an X25519 ephemeral keypair for one DM key exchange.
///
/// The `EphemeralSecret` is consumed by `diffie_hellman`, ensuring it cannot
/// be reused. Call `derive_dm_key` (in `aead`) on the resulting shared secret.
pub fn generate_ephemeral() -> (EphemeralSecret, PublicKey) {
let secret = EphemeralSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
(secret, public)
}
/// Encode an X25519 public key to base64.
pub fn pubkey_to_b64(key: &PublicKey) -> String {
B64.encode(key.as_bytes())
}
/// Decode a base64 string to an X25519 public key.
pub fn b64_to_pubkey(s: &str) -> Result<PublicKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(PublicKey::from(arr))
}
/// Perform X25519 Diffie-Hellman and return the 32-byte shared secret.
///
/// `secret` is consumed (ephemeral), preventing reuse.
pub fn diffie_hellman(secret: EphemeralSecret, their_pubkey: &PublicKey) -> [u8; 32] {
let shared = secret.diffie_hellman(their_pubkey);
*shared.as_bytes()
}

View File

@@ -0,0 +1,67 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use crate::error::{BackchannelError, Result};
/// Generate a new Ed25519 identity keypair.
pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
(signing_key, verifying_key)
}
/// Encode an Ed25519 public key to a base64 string (32 bytes → ~44 chars).
pub fn pubkey_to_b64(key: &VerifyingKey) -> String {
B64.encode(key.as_bytes())
}
/// Decode a base64 string back to an Ed25519 public key.
pub fn b64_to_pubkey(s: &str) -> Result<VerifyingKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
VerifyingKey::from_bytes(&arr).map_err(|e| BackchannelError::Crypto(e.to_string()))
}
/// Encode a signing key (private key bytes) to base64 for keystore persistence.
pub fn signing_key_to_b64(key: &SigningKey) -> String {
B64.encode(key.to_bytes())
}
/// Decode a base64 string back to a signing key.
pub fn b64_to_signing_key(s: &str) -> Result<SigningKey> {
let bytes = B64.decode(s)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(SigningKey::from_bytes(&arr))
}
/// Sign a message with an Ed25519 signing key.
pub fn sign(key: &SigningKey, message: &[u8]) -> Signature {
use ed25519_dalek::Signer;
key.sign(message)
}
/// Verify an Ed25519 signature.
pub fn verify(key: &VerifyingKey, message: &[u8], sig: &Signature) -> Result<()> {
use ed25519_dalek::Verifier;
key.verify(message, sig)?;
Ok(())
}
/// Encode a `Signature` to base64.
pub fn sig_to_b64(sig: &Signature) -> String {
B64.encode(sig.to_bytes())
}
/// Decode a base64 string back to a `Signature`.
pub fn b64_to_sig(s: &str) -> Result<Signature> {
let bytes = B64.decode(s)?;
let arr: [u8; 64] = bytes
.try_into()
.map_err(|_| BackchannelError::InvalidKeyLength)?;
Ok(Signature::from_bytes(&arr))
}

View File

@@ -0,0 +1,3 @@
pub mod aead;
pub mod ecdh;
pub mod identity;

View File

@@ -0,0 +1,39 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, BackchannelError>;
#[derive(Debug, Error)]
pub enum BackchannelError {
#[error("cryptographic error: {0}")]
Crypto(String),
#[error("base64 decode error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("invalid key length")]
InvalidKeyLength,
#[error("decryption failed: authentication tag mismatch")]
DecryptionFailed,
#[error("signature verification failed")]
InvalidSignature,
#[error("{0}")]
Other(String),
}
impl From<chacha20poly1305::Error> for BackchannelError {
fn from(_: chacha20poly1305::Error) -> Self {
BackchannelError::DecryptionFailed
}
}
impl From<ed25519_dalek::SignatureError> for BackchannelError {
fn from(_: ed25519_dalek::SignatureError) -> Self {
BackchannelError::InvalidSignature
}
}

View File

@@ -0,0 +1,6 @@
pub mod crypto;
pub mod error;
pub mod protocol;
pub mod types;
pub use error::{BackchannelError, Result};

View File

@@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Every message the client can send to the server over the WebSocket.
///
/// Serialised as JSON with an inline `"type"` discriminant, e.g.:
/// `{"type":"login","username":"alice","password":"...","identity_pubkey":"..."}`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
// ── Authentication ────────────────────────────────────────────────────
/// First-time authentication with credentials.
Login {
username: String,
/// Sent in plaintext; the TLS layer protects this in transit.
password: String,
/// Base64-encoded Ed25519 public key (32 bytes).
/// Stored/updated server-side as the user's cryptographic identity.
identity_pubkey: String,
},
/// Register a new account.
Register {
username: String,
password: String,
identity_pubkey: String,
},
/// Re-establish an authenticated session using a previously issued JWT.
/// Avoids re-entering the password on reconnect.
ResumeSession {
token: String,
},
/// Gracefully end the session; server invalidates the JWT.
Logout,
// ── Channel messaging ─────────────────────────────────────────────────
/// Post a plaintext message to a channel.
SendChannelMessage {
channel_id: Uuid,
content: String,
},
/// Request recent message history for a channel.
FetchChannelHistory {
channel_id: Uuid,
/// Pagination: fetch messages older than this ID.
before_message_id: Option<Uuid>,
limit: u32,
},
// ── Direct messages (E2EE) ────────────────────────────────────────────
/// Phase 1 of DM key exchange: initiator sends their X25519 ephemeral
/// public key to the server, addressed to the target recipient.
InitDmKeyExchange {
recipient_id: Uuid,
/// Base64-encoded X25519 ephemeral public key (32 bytes).
sender_ephemeral_pubkey: String,
},
/// Phase 2 of DM key exchange: recipient responds with their ephemeral
/// public key. Both sides can now derive the same shared secret.
AcceptDmKeyExchange {
initiator_id: Uuid,
/// Base64-encoded X25519 ephemeral public key (32 bytes).
recipient_ephemeral_pubkey: String,
},
/// Send an E2EE DM. The server relays the ciphertext without decrypting.
SendDm {
recipient_id: Uuid,
/// Base64-encoded ChaCha20-Poly1305 ciphertext.
ciphertext: String,
/// Base64-encoded nonce (12 bytes).
nonce: String,
},
/// Request DM history with a specific peer.
FetchDmHistory {
peer_id: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
},
// ── Channel management ────────────────────────────────────────────────
CreateChannel {
name: String,
topic: Option<String>,
},
DeleteChannel {
channel_id: Uuid,
},
// ── Role & permission management ──────────────────────────────────────
CreateRole {
name: String,
/// Permission bitmask; see `PermissionFlags` in `types.rs`.
permissions: u64,
},
AssignRole {
user_id: Uuid,
role_id: Uuid,
},
RevokeRole {
user_id: Uuid,
role_id: Uuid,
},
// ── Key directory ─────────────────────────────────────────────────────
/// Look up another user's registered Ed25519 identity public key.
QueryIdentityKey {
user_id: Uuid,
},
// ── Keepalive ─────────────────────────────────────────────────────────
Ping,
}

View File

@@ -0,0 +1,5 @@
pub mod client;
pub mod server;
pub use client::ClientMessage;
pub use server::{HistoricChannelMessage, HistoricDm, ServerMessage};

View File

@@ -0,0 +1,141 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Every message the server can push to a client over the WebSocket.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
// ── Auth responses ────────────────────────────────────────────────────
AuthSuccess {
user_id: Uuid,
username: String,
/// Signed JWT. Client stores this for reconnect via `ResumeSession`.
session_token: String,
},
AuthError {
reason: String,
},
// ── Channel events ────────────────────────────────────────────────────
/// Broadcast to all connected clients when a channel message is posted.
ChannelMessage {
message_id: Uuid,
channel_id: Uuid,
author_id: Uuid,
author_username: String,
content: String,
timestamp: DateTime<Utc>,
},
/// Response to `FetchChannelHistory`.
ChannelHistory {
channel_id: Uuid,
messages: Vec<HistoricChannelMessage>,
},
ChannelCreated {
channel_id: Uuid,
name: String,
topic: Option<String>,
},
ChannelDeleted {
channel_id: Uuid,
},
// ── DM key exchange relay ─────────────────────────────────────────────
/// Server relays the initiator's ephemeral pubkey to the recipient.
DmKeyExchangeRequest {
initiator_id: Uuid,
initiator_username: String,
sender_ephemeral_pubkey: String,
},
/// Server relays the recipient's ephemeral pubkey back to the initiator.
DmKeyExchangeResponse {
recipient_id: Uuid,
recipient_username: String,
recipient_ephemeral_pubkey: String,
},
// ── DM messages ───────────────────────────────────────────────────────
/// Relayed E2EE DM. Server cannot read `ciphertext`.
DirectMessage {
message_id: Uuid,
sender_id: Uuid,
sender_username: String,
ciphertext: String,
nonce: String,
timestamp: DateTime<Utc>,
},
DmHistory {
peer_id: Uuid,
messages: Vec<HistoricDm>,
},
// ── Presence ──────────────────────────────────────────────────────────
Pong,
UserOnline {
user_id: Uuid,
username: String,
},
UserOffline {
user_id: Uuid,
username: String,
},
// ── Role events ───────────────────────────────────────────────────────
RoleCreated {
role_id: Uuid,
name: String,
permissions: u64,
},
RoleAssigned {
user_id: Uuid,
role_id: Uuid,
},
RoleRevoked {
user_id: Uuid,
role_id: Uuid,
},
// ── Key directory ─────────────────────────────────────────────────────
IdentityKeyResponse {
user_id: Uuid,
/// Base64-encoded Ed25519 public key, or null if unknown.
identity_pubkey: Option<String>,
},
// ── Generic error ─────────────────────────────────────────────────────
Error {
code: u16,
message: String,
},
}
/// A channel message returned in history responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricChannelMessage {
pub message_id: Uuid,
pub author_id: Uuid,
pub author_username: String,
pub content: String,
pub timestamp: DateTime<Utc>,
}
/// A DM returned in history responses. Ciphertext is opaque to the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricDm {
pub message_id: Uuid,
pub sender_id: Uuid,
pub ciphertext: String,
pub nonce: String,
pub timestamp: DateTime<Utc>,
}

View File

@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// Typed UUID wrappers to prevent accidental mixing of IDs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageId(pub Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RoleId(pub Uuid);
impl UserId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl ChannelId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl MessageId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
impl RoleId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn inner(self) -> Uuid {
self.0
}
}
/// Permission bit-flags for server roles.
/// The `ADMINISTRATOR` flag bypasses all individual permission checks.
pub struct PermissionFlags;
impl PermissionFlags {
pub const READ_MESSAGES: u64 = 1 << 0;
pub const SEND_MESSAGES: u64 = 1 << 1;
pub const MANAGE_CHANNELS: u64 = 1 << 2;
pub const MANAGE_ROLES: u64 = 1 << 3;
pub const KICK_MEMBERS: u64 = 1 << 4;
pub const BAN_MEMBERS: u64 = 1 << 5;
/// Bypasses all permission checks.
pub const ADMINISTRATOR: u64 = 1 << 63;
}
/// Check whether `perms` satisfies a required flag, honoring ADMINISTRATOR.
pub fn has_permission(perms: u64, required: u64) -> bool {
(perms & PermissionFlags::ADMINISTRATOR) != 0 || (perms & required) == required
}

View File

@@ -0,0 +1,36 @@
[package]
name = "backchannel-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "backchannel-server"
path = "src/main.rs"
[dependencies]
backchannel-common = { path = "../backchannel-common" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
base64 = { workspace = true }
rand = { workspace = true }
argon2 = { workspace = true }
ed25519-dalek = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "migrate", "chrono", "uuid"] }
jsonwebtoken = "9"
rustls = { version = "0.23", features = ["ring"] }
rustls-pemfile = "2"
tokio-rustls = "0.26"
futures-util = "0.3"
axum = "0.7"
rust-embed = "8"
mime_guess = "2"

View File

@@ -0,0 +1,2 @@
pub mod password;
pub mod token;

View File

@@ -0,0 +1,31 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use crate::error::{Result, ServerError};
/// Hash a plaintext password using Argon2id.
///
/// The returned string is the PHC-format encoded hash, suitable for storage in
/// the `users.password_hash` column. Includes the salt, so no separate salt
/// storage is needed.
pub fn hash(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
/// Verify a plaintext password against a stored PHC-format hash.
///
/// Returns `Ok(())` on success, `Err(ServerError::Unauthorized)` on mismatch.
pub fn verify(password: &str, stored_hash: &str) -> Result<()> {
let parsed = PasswordHash::new(stored_hash)
.map_err(|e| ServerError::PasswordHash(e.to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| ServerError::Unauthorized)
}

View File

@@ -0,0 +1,110 @@
use chrono::Utc;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::{Result, ServerError};
/// Claims embedded in every BackChannel JWT.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
/// Subject: user UUID as a string.
pub sub: String,
pub username: String,
/// JWT ID — stored in the `sessions` table for revocation support.
pub jti: String,
/// Issued-at (Unix seconds).
pub iat: i64,
/// Expiry (Unix seconds).
pub exp: i64,
}
impl Claims {
pub fn user_id(&self) -> Result<Uuid> {
Uuid::parse_str(&self.sub).map_err(|e| ServerError::Internal(e.to_string()))
}
}
const TOKEN_TTL_SECS: i64 = 7 * 24 * 3600; // 7 days
/// Issue a signed JWT and persist its JTI in the `sessions` table.
pub async fn issue(
pool: &SqlitePool,
user_id: Uuid,
username: &str,
secret: &[u8],
) -> Result<String> {
let now = Utc::now().timestamp();
let jti = Uuid::new_v4().to_string();
let claims = Claims {
sub: user_id.to_string(),
username: username.to_string(),
jti: jti.clone(),
iat: now,
exp: now + TOKEN_TTL_SECS,
};
let token = encode(
&Header::default(), // HS256
&claims,
&EncodingKey::from_secret(secret),
)?;
// Persist JTI for revocation checks.
let expires_at = Utc::now()
.checked_add_signed(chrono::Duration::seconds(TOKEN_TTL_SECS))
.unwrap_or_else(Utc::now)
.to_rfc3339();
sqlx::query(
"INSERT INTO sessions (jti, user_id, expires_at) VALUES (?, ?, ?)",
)
.bind(&jti)
.bind(user_id.to_string())
.bind(expires_at)
.execute(pool)
.await?;
Ok(token)
}
/// Decode and validate a JWT, then check the `sessions` table for revocation.
pub async fn validate(pool: &SqlitePool, token: &str, secret: &[u8]) -> Result<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret),
&validation,
)
.map_err(|_| ServerError::Unauthorized)?;
let claims = token_data.claims;
// Check revocation.
let row: Option<(i64,)> =
sqlx::query_as("SELECT revoked FROM sessions WHERE jti = ?")
.bind(&claims.jti)
.fetch_optional(pool)
.await?;
match row {
None => return Err(ServerError::Unauthorized), // JTI unknown
Some((revoked,)) if revoked != 0 => return Err(ServerError::Unauthorized),
_ => {}
}
Ok(claims)
}
/// Mark a JWT as revoked (logout / forced expiry).
pub async fn revoke(pool: &SqlitePool, jti: &str) -> Result<()> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE jti = ?")
.bind(jti)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -0,0 +1,84 @@
use anyhow::Result;
/// Runtime configuration for the BackChannel server.
/// Loaded from environment variables with sensible development defaults.
pub struct ServerConfig {
/// TCP address to bind, e.g. `0.0.0.0:7777`
pub bind_addr: String,
/// HTTP address for the embedded web UI, e.g. `0.0.0.0:8080`
pub http_bind_addr: String,
/// SQLite connection string, e.g. `sqlite:backchannel.db`
pub db_url: String,
/// Raw bytes for HMAC-SHA256 JWT signing. In production, set
/// `BC_JWT_SECRET` to a long, random hex string.
pub jwt_secret: Vec<u8>,
/// Enable TLS on the WebSocket listener. Requires `tls_cert_path`
/// and `tls_key_path` when true.
pub tls_enabled: bool,
/// Path to the PEM-encoded TLS certificate chain.
pub tls_cert_path: Option<String>,
/// Path to the PEM-encoded private key.
pub tls_key_path: Option<String>,
}
impl ServerConfig {
/// Load configuration from environment variables.
///
/// | Variable | Default |
/// |------------------|-----------------------------|
/// | `BC_BIND_ADDR` | `0.0.0.0:7777` |
/// | `BC_HTTP_ADDR` | `0.0.0.0:8080` |
/// | `BC_DB_URL` | `sqlite:backchannel.db` |
/// | `BC_JWT_SECRET` | *insecure dev placeholder* |
/// | `BC_TLS` | `false` |
/// | `BC_TLS_CERT` | *(none)* |
/// | `BC_TLS_KEY` | *(none)* |
pub fn from_env() -> Result<Self> {
let bind_addr = std::env::var("BC_BIND_ADDR")
.unwrap_or_else(|_| "0.0.0.0:7777".into());
let http_bind_addr = std::env::var("BC_HTTP_ADDR")
.unwrap_or_else(|_| "0.0.0.0:8080".into());
let db_url = std::env::var("BC_DB_URL")
.unwrap_or_else(|_| "sqlite:backchannel.db".into());
let jwt_secret = match std::env::var("BC_JWT_SECRET") {
Ok(s) => s.into_bytes(),
Err(_) => {
tracing::warn!(
"BC_JWT_SECRET not set — using insecure dev placeholder. \
Set this env var before deploying."
);
b"CHANGE-ME-dev-only-jwt-secret-32b".to_vec()
}
};
let tls_enabled = std::env::var("BC_TLS")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
let tls_cert_path = std::env::var("BC_TLS_CERT").ok();
let tls_key_path = std::env::var("BC_TLS_KEY").ok();
if tls_enabled {
anyhow::ensure!(tls_cert_path.is_some(), "BC_TLS_CERT must be set when BC_TLS=true");
anyhow::ensure!(tls_key_path.is_some(), "BC_TLS_KEY must be set when BC_TLS=true");
}
Ok(Self {
bind_addr,
http_bind_addr,
db_url,
jwt_secret,
tls_enabled,
tls_cert_path,
tls_key_path,
})
}
}

View File

@@ -0,0 +1,78 @@
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::{Result, ServerError};
#[derive(Debug, sqlx::FromRow)]
pub struct ChannelRow {
pub id: String,
pub name: String,
pub topic: Option<String>,
pub created_by: String,
pub created_at: String,
}
impl ChannelRow {
pub fn uuid(&self) -> Result<Uuid> {
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
}
}
pub async fn create(
pool: &SqlitePool,
name: &str,
topic: Option<&str>,
created_by: Uuid,
) -> Result<Uuid> {
let id = Uuid::new_v4();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO channels (id, name, topic, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind(name)
.bind(topic)
.bind(created_by.to_string())
.bind(now)
.execute(pool)
.await?;
Ok(id)
}
pub async fn list(pool: &SqlitePool) -> Result<Vec<ChannelRow>> {
let rows = sqlx::query_as::<_, ChannelRow>(
"SELECT id, name, topic, created_by, created_at FROM channels ORDER BY created_at ASC",
)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<ChannelRow>> {
let row = sqlx::query_as::<_, ChannelRow>(
"SELECT id, name, topic, created_by, created_at FROM channels WHERE id = ?",
)
.bind(id.to_string())
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<bool> {
let result = sqlx::query("DELETE FROM channels WHERE id = ?")
.bind(id.to_string())
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn name_exists(pool: &SqlitePool, name: &str) -> Result<bool> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels WHERE name = ?")
.bind(name)
.fetch_one(pool)
.await?;
Ok(count.0 > 0)
}

View File

@@ -0,0 +1,17 @@
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::Result;
/// Look up the registered Ed25519 identity public key for a user.
///
/// Returns `None` if the user does not exist or has no key on record
/// (the latter shouldn't happen after registration, but is handled defensively).
pub async fn get_identity_key(pool: &SqlitePool, user_id: Uuid) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT identity_pubkey FROM users WHERE id = ?")
.bind(user_id.to_string())
.fetch_optional(pool)
.await?;
Ok(row.map(|(k,)| k))
}

View File

@@ -0,0 +1,210 @@
use backchannel_common::protocol::server::{HistoricChannelMessage, HistoricDm};
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::{Result, ServerError};
#[derive(Debug, sqlx::FromRow)]
struct ChannelMessageRow {
pub id: String,
pub channel_id: String,
pub author_id: String,
pub author_username: String,
pub content: String,
pub created_at: String,
}
#[derive(Debug, sqlx::FromRow)]
struct DmRow {
pub id: String,
pub sender_id: String,
pub ciphertext: String,
pub nonce: String,
pub created_at: String,
}
pub async fn insert_channel_message(
pool: &SqlitePool,
channel_id: Uuid,
author_id: Uuid,
content: &str,
) -> Result<(Uuid, DateTime<Utc>)> {
let id = Uuid::new_v4();
let now = Utc::now();
let now_str = now.to_rfc3339();
sqlx::query(
"INSERT INTO channel_messages (id, channel_id, author_id, content, created_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind(channel_id.to_string())
.bind(author_id.to_string())
.bind(content)
.bind(&now_str)
.execute(pool)
.await?;
Ok((id, now))
}
pub async fn fetch_channel_history(
pool: &SqlitePool,
channel_id: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
) -> Result<Vec<HistoricChannelMessage>> {
let limit = limit.min(100) as i64;
let rows: Vec<ChannelMessageRow> = if let Some(before_id) = before_message_id {
// Fetch messages older than `before_id` by joining to get its created_at
sqlx::query_as::<_, ChannelMessageRow>(
"SELECT m.id, m.channel_id, m.author_id, u.username AS author_username,
m.content, m.created_at
FROM channel_messages m
JOIN users u ON u.id = m.author_id
WHERE m.channel_id = ?
AND m.created_at < (SELECT created_at FROM channel_messages WHERE id = ?)
ORDER BY m.created_at DESC
LIMIT ?",
)
.bind(channel_id.to_string())
.bind(before_id.to_string())
.bind(limit)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, ChannelMessageRow>(
"SELECT m.id, m.channel_id, m.author_id, u.username AS author_username,
m.content, m.created_at
FROM channel_messages m
JOIN users u ON u.id = m.author_id
WHERE m.channel_id = ?
ORDER BY m.created_at DESC
LIMIT ?",
)
.bind(channel_id.to_string())
.bind(limit)
.fetch_all(pool)
.await?
};
let mut messages: Vec<HistoricChannelMessage> = rows
.into_iter()
.map(|r| {
let ts = r
.created_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now());
Ok(HistoricChannelMessage {
message_id: Uuid::parse_str(&r.id)
.map_err(|e| ServerError::Internal(e.to_string()))?,
author_id: Uuid::parse_str(&r.author_id)
.map_err(|e| ServerError::Internal(e.to_string()))?,
author_username: r.author_username,
content: r.content,
timestamp: ts,
})
})
.collect::<Result<Vec<_>>>()?;
// Return in chronological order (oldest first).
messages.reverse();
Ok(messages)
}
pub async fn insert_dm(
pool: &SqlitePool,
sender_id: Uuid,
recipient_id: Uuid,
ciphertext: &str,
nonce: &str,
) -> Result<(Uuid, DateTime<Utc>)> {
let id = Uuid::new_v4();
let now = Utc::now();
let now_str = now.to_rfc3339();
sqlx::query(
"INSERT INTO direct_messages (id, sender_id, recipient_id, ciphertext, nonce, created_at)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind(sender_id.to_string())
.bind(recipient_id.to_string())
.bind(ciphertext)
.bind(nonce)
.bind(&now_str)
.execute(pool)
.await?;
Ok((id, now))
}
pub async fn fetch_dm_history(
pool: &SqlitePool,
user_a: Uuid,
user_b: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
) -> Result<Vec<HistoricDm>> {
let limit = limit.min(100) as i64;
let rows: Vec<DmRow> = if let Some(before_id) = before_message_id {
sqlx::query_as::<_, DmRow>(
"SELECT id, sender_id, ciphertext, nonce, created_at
FROM direct_messages
WHERE ((sender_id = ? AND recipient_id = ?)
OR (sender_id = ? AND recipient_id = ?))
AND created_at < (SELECT created_at FROM direct_messages WHERE id = ?)
ORDER BY created_at DESC
LIMIT ?",
)
.bind(user_a.to_string())
.bind(user_b.to_string())
.bind(user_b.to_string())
.bind(user_a.to_string())
.bind(before_id.to_string())
.bind(limit)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, DmRow>(
"SELECT id, sender_id, ciphertext, nonce, created_at
FROM direct_messages
WHERE (sender_id = ? AND recipient_id = ?)
OR (sender_id = ? AND recipient_id = ?)
ORDER BY created_at DESC
LIMIT ?",
)
.bind(user_a.to_string())
.bind(user_b.to_string())
.bind(user_b.to_string())
.bind(user_a.to_string())
.bind(limit)
.fetch_all(pool)
.await?
};
let mut messages: Vec<HistoricDm> = rows
.into_iter()
.map(|r| {
let ts = r
.created_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now());
Ok(HistoricDm {
message_id: Uuid::parse_str(&r.id)
.map_err(|e| ServerError::Internal(e.to_string()))?,
sender_id: Uuid::parse_str(&r.sender_id)
.map_err(|e| ServerError::Internal(e.to_string()))?,
ciphertext: r.ciphertext,
nonce: r.nonce,
timestamp: ts,
})
})
.collect::<Result<Vec<_>>>()?;
messages.reverse();
Ok(messages)
}

View File

@@ -0,0 +1,85 @@
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
identity_pubkey TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- ── Roles ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS roles (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
permissions INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Seed built-in roles.
-- admin: all bits set (i64::MAX in SQLite integer storage).
-- member: READ_MESSAGES (bit 0) | SEND_MESSAGES (bit 1) = 3
INSERT OR IGNORE INTO roles (id, name, permissions) VALUES
('00000000-0000-0000-0000-000000000001', 'admin', 9223372036854775807),
('00000000-0000-0000-0000-000000000002', 'member', 3);
-- ── User ↔ Role membership ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS user_roles (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, role_id)
);
-- ── Channels ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS channels (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
topic TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Seed a default channel owned by a system placeholder user.
-- The created_by value references no real user; foreign key is intentionally
-- left loose here since the system user is never inserted.
INSERT OR IGNORE INTO channels (id, name, created_by) VALUES
('00000000-0000-0000-0000-000000000010', 'general', '00000000-0000-0000-0000-000000000000');
-- ── Channel messages ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS channel_messages (
id TEXT NOT NULL PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_channel_messages_channel_created
ON channel_messages (channel_id, created_at DESC);
-- ── Direct messages ────────────────────────────────────────────────────────────
-- Ciphertext is stored opaquely. The server cannot decrypt DM content.
CREATE TABLE IF NOT EXISTS direct_messages (
id TEXT NOT NULL PRIMARY KEY,
sender_id TEXT NOT NULL REFERENCES users(id),
recipient_id TEXT NOT NULL REFERENCES users(id),
ciphertext TEXT NOT NULL,
nonce TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_dm_pair_created
ON direct_messages (sender_id, recipient_id, created_at DESC);
-- ── Sessions ───────────────────────────────────────────────────────────────────
-- JWT IDs stored here enable soft revocation (logout / forced expiry).
CREATE TABLE IF NOT EXISTS sessions (
jti TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);

View File

@@ -0,0 +1,5 @@
pub mod channels;
pub mod keys;
pub mod messages;
pub mod roles;
pub mod users;

View File

@@ -0,0 +1,86 @@
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::{Result, ServerError};
#[derive(Debug, sqlx::FromRow)]
pub struct RoleRow {
pub id: String,
pub name: String,
pub permissions: i64,
pub created_at: String,
}
impl RoleRow {
pub fn uuid(&self) -> Result<Uuid> {
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
}
/// Returns the permissions bitmask as `u64` (SQLite stores it as `i64`).
pub fn permissions_u64(&self) -> u64 {
self.permissions as u64
}
}
pub async fn create(pool: &SqlitePool, name: &str, permissions: u64) -> Result<Uuid> {
let id = Uuid::new_v4();
let now = Utc::now().to_rfc3339();
sqlx::query("INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)")
.bind(id.to_string())
.bind(name)
.bind(permissions as i64)
.bind(now)
.execute(pool)
.await?;
Ok(id)
}
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<RoleRow>> {
let row = sqlx::query_as::<_, RoleRow>(
"SELECT id, name, permissions, created_at FROM roles WHERE id = ?",
)
.bind(id.to_string())
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn assign(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)",
)
.bind(user_id.to_string())
.bind(role_id.to_string())
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn revoke(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
.bind(user_id.to_string())
.bind(role_id.to_string())
.execute(pool)
.await?;
Ok(())
}
/// Compute the effective permission bitmask for a user by OR-ing all their role permissions.
pub async fn get_user_permissions(pool: &SqlitePool, user_id: Uuid) -> Result<u64> {
let rows: Vec<(i64,)> = sqlx::query_as(
"SELECT r.permissions FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = ?",
)
.bind(user_id.to_string())
.fetch_all(pool)
.await?;
let perms = rows.into_iter().fold(0u64, |acc, (p,)| acc | p as u64);
Ok(perms)
}

View File

@@ -0,0 +1,115 @@
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::error::{Result, ServerError};
/// A row from the `users` table.
#[derive(Debug, sqlx::FromRow)]
pub struct UserRow {
pub id: String,
pub username: String,
pub password_hash: String,
pub identity_pubkey: String,
pub created_at: String,
pub updated_at: String,
}
impl UserRow {
pub fn uuid(&self) -> Result<Uuid> {
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
}
}
/// Insert a new user. Returns the new row's UUID.
pub async fn create(
pool: &SqlitePool,
username: &str,
password_hash: &str,
identity_pubkey: &str,
) -> Result<Uuid> {
let id = Uuid::new_v4();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO users (id, username, password_hash, identity_pubkey, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind(username)
.bind(password_hash)
.bind(identity_pubkey)
.bind(&now)
.bind(&now)
.execute(pool)
.await?;
Ok(id)
}
/// Find a user by username.
pub async fn find_by_username(pool: &SqlitePool, username: &str) -> Result<Option<UserRow>> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, password_hash, identity_pubkey, created_at, updated_at
FROM users WHERE username = ?",
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Find a user by UUID.
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<UserRow>> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, password_hash, identity_pubkey, created_at, updated_at
FROM users WHERE id = ?",
)
.bind(id.to_string())
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Update (or set for the first time) a user's Ed25519 identity public key.
/// Called on every login to allow transparent key rotation.
pub async fn update_identity_key(
pool: &SqlitePool,
user_id: Uuid,
identity_pubkey: &str,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query("UPDATE users SET identity_pubkey = ?, updated_at = ? WHERE id = ?")
.bind(identity_pubkey)
.bind(now)
.bind(user_id.to_string())
.execute(pool)
.await?;
Ok(())
}
/// Check whether a username is already taken.
pub async fn username_exists(pool: &SqlitePool, username: &str) -> Result<bool> {
let count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE username = ?")
.bind(username)
.fetch_one(pool)
.await?;
Ok(count.0 > 0)
}
/// Assign the built-in `member` role to a newly registered user.
pub async fn assign_member_role(pool: &SqlitePool, user_id: Uuid) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)",
)
.bind(user_id.to_string())
.bind("00000000-0000-0000-0000-000000000002") // member role UUID
.bind(now)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -0,0 +1,59 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ServerError>;
#[derive(Debug, Error)]
pub enum ServerError {
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("connection closed")]
ConnectionClosed,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[error("password hash error: {0}")]
PasswordHash(String),
#[error("common error: {0}")]
Common(#[from] backchannel_common::BackchannelError),
#[error("internal error: {0}")]
Internal(String),
}
impl From<argon2::password_hash::Error> for ServerError {
fn from(e: argon2::password_hash::Error) -> Self {
ServerError::PasswordHash(e.to_string())
}
}
/// HTTP-style error code for `ServerMessage::Error`.
impl ServerError {
pub fn code(&self) -> u16 {
match self {
ServerError::Unauthorized => 401,
ServerError::Forbidden => 403,
ServerError::NotFound(_) => 404,
ServerError::BadRequest(_) => 400,
ServerError::ConnectionClosed => 503,
_ => 500,
}
}
}

View File

@@ -0,0 +1,124 @@
use std::sync::Arc;
use backchannel_common::protocol::ServerMessage;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use crate::auth::{password, token};
use crate::db::users;
use crate::error::{Result, ServerError};
use crate::state::{AppState, ChannelBroadcast};
use crate::ws::session::Session;
pub async fn handle_login(
session: &mut Session,
state: &Arc<AppState>,
username: String,
password_plain: String,
identity_pubkey: String,
) -> Result<()> {
let user = users::find_by_username(&state.db, &username)
.await?
.ok_or(ServerError::Unauthorized)?;
password::verify(&password_plain, &user.password_hash)?;
let user_id = user.uuid()?;
// Update identity key on every login to allow transparent key rotation.
users::update_identity_key(&state.db, user_id, &identity_pubkey).await?;
let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?;
let jti = extract_jti(&jwt, &state.jwt_secret)?;
session.user_id = Some(user_id);
session.username = Some(username.clone());
session.jti = Some(jti);
state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::UserOnline { user_id, username: username.clone() },
});
session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt })
}
pub async fn handle_register(
session: &mut Session,
state: &Arc<AppState>,
username: String,
password_plain: String,
identity_pubkey: String,
) -> Result<()> {
if username.len() < 2 || username.len() > 32 {
return Err(ServerError::BadRequest("Username must be 232 characters".into()));
}
if password_plain.len() < 8 {
return Err(ServerError::BadRequest("Password must be at least 8 characters".into()));
}
if users::username_exists(&state.db, &username).await? {
return Err(ServerError::BadRequest("Username already taken".into()));
}
let hash = password::hash(&password_plain)?;
let user_id = users::create(&state.db, &username, &hash, &identity_pubkey).await?;
users::assign_member_role(&state.db, user_id).await?;
let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?;
let jti = extract_jti(&jwt, &state.jwt_secret)?;
session.user_id = Some(user_id);
session.username = Some(username.clone());
session.jti = Some(jti);
state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::UserOnline { user_id, username: username.clone() },
});
session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt })
}
pub async fn handle_resume(
session: &mut Session,
state: &Arc<AppState>,
token_str: String,
) -> Result<()> {
let claims = token::validate(&state.db, &token_str, &state.jwt_secret).await?;
let user_id = claims.user_id()?;
session.user_id = Some(user_id);
session.username = Some(claims.username.clone());
session.jti = Some(claims.jti.clone());
state.register_session(session.conn_id, user_id, claims.username.clone(), session.tx.clone()).await;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::UserOnline { user_id, username: claims.username.clone() },
});
session.send(ServerMessage::AuthSuccess {
user_id,
username: claims.username,
session_token: token_str,
})
}
pub async fn handle_logout(session: &mut Session, state: &Arc<AppState>) -> Result<()> {
session.require_auth()?;
if let Some(jti) = session.jti.take() {
token::revoke(&state.db, &jti).await?;
}
session.user_id = None;
session.username = None;
Ok(())
}
/// Extract the JTI claim from a JWT without re-running expiry validation.
/// Used immediately after `token::issue` to store the JTI in the session.
fn extract_jti(jwt: &str, secret: &[u8]) -> Result<String> {
let mut v = Validation::new(Algorithm::HS256);
v.validate_exp = false;
let data = decode::<token::Claims>(jwt, &DecodingKey::from_secret(secret), &v)?;
Ok(data.claims.jti)
}

View File

@@ -0,0 +1,107 @@
use std::sync::Arc;
use backchannel_common::protocol::ServerMessage;
use backchannel_common::types::{has_permission, PermissionFlags};
use uuid::Uuid;
use crate::db::{channels, messages, roles};
use crate::error::{Result, ServerError};
use crate::state::{AppState, ChannelBroadcast};
use crate::ws::session::Session;
pub async fn handle_send(
session: &Session,
state: &Arc<AppState>,
channel_id: Uuid,
content: String,
) -> Result<()> {
let user_id = session.require_auth()?;
let username = session.require_username()?.to_string();
channels::find_by_id(&state.db, channel_id)
.await?
.ok_or_else(|| ServerError::NotFound("Channel not found".into()))?;
let perms = roles::get_user_permissions(&state.db, user_id).await?;
if !has_permission(perms, PermissionFlags::SEND_MESSAGES) {
return Err(ServerError::Forbidden);
}
let (message_id, timestamp) =
messages::insert_channel_message(&state.db, channel_id, user_id, &content).await?;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::ChannelMessage {
message_id,
channel_id,
author_id: user_id,
author_username: username,
content,
timestamp,
},
});
Ok(())
}
pub async fn handle_history(
session: &Session,
state: &Arc<AppState>,
channel_id: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
) -> Result<()> {
let user_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, user_id).await?;
if !has_permission(perms, PermissionFlags::READ_MESSAGES) {
return Err(ServerError::Forbidden);
}
let msgs = messages::fetch_channel_history(&state.db, channel_id, before_message_id, limit).await?;
session.send(ServerMessage::ChannelHistory { channel_id, messages: msgs })
}
pub async fn handle_create(
session: &Session,
state: &Arc<AppState>,
name: String,
topic: Option<String>,
) -> Result<()> {
let user_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, user_id).await?;
if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) {
return Err(ServerError::Forbidden);
}
if channels::name_exists(&state.db, &name).await? {
return Err(ServerError::BadRequest("Channel name already exists".into()));
}
let channel_id = channels::create(&state.db, &name, topic.as_deref(), user_id).await?;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::ChannelCreated { channel_id, name, topic },
});
Ok(())
}
pub async fn handle_delete(
session: &Session,
state: &Arc<AppState>,
channel_id: Uuid,
) -> Result<()> {
let user_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, user_id).await?;
if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) {
return Err(ServerError::Forbidden);
}
if !channels::delete(&state.db, channel_id).await? {
return Err(ServerError::NotFound("Channel not found".into()));
}
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::ChannelDeleted { channel_id },
});
Ok(())
}

View File

@@ -0,0 +1,115 @@
use std::sync::Arc;
use backchannel_common::protocol::ServerMessage;
use uuid::Uuid;
use crate::db::messages;
use crate::error::Result;
use crate::state::AppState;
use crate::ws::session::Session;
pub async fn handle_init_key_exchange(
session: &Session,
state: &Arc<AppState>,
recipient_id: Uuid,
sender_ephemeral_pubkey: String,
) -> Result<()> {
let initiator_id = session.require_auth()?;
let initiator_username = session.require_username()?.to_string();
// Store pending exchange in AppState (cleared once recipient accepts).
state
.pending_key_exchanges
.lock()
.await
.insert((initiator_id, recipient_id), sender_ephemeral_pubkey.clone());
// Relay to recipient if they're currently connected.
state
.send_to_user(
recipient_id,
&ServerMessage::DmKeyExchangeRequest {
initiator_id,
initiator_username,
sender_ephemeral_pubkey,
},
)
.await;
Ok(())
}
pub async fn handle_accept_key_exchange(
session: &Session,
state: &Arc<AppState>,
initiator_id: Uuid,
recipient_ephemeral_pubkey: String,
) -> Result<()> {
let recipient_id = session.require_auth()?;
let recipient_username = session.require_username()?.to_string();
// Clear the pending exchange.
state
.pending_key_exchanges
.lock()
.await
.remove(&(initiator_id, recipient_id));
// Relay response back to initiator.
state
.send_to_user(
initiator_id,
&ServerMessage::DmKeyExchangeResponse {
recipient_id,
recipient_username,
recipient_ephemeral_pubkey,
},
)
.await;
Ok(())
}
pub async fn handle_send_dm(
session: &Session,
state: &Arc<AppState>,
recipient_id: Uuid,
ciphertext: String,
nonce: String,
) -> Result<()> {
let sender_id = session.require_auth()?;
let sender_username = session.require_username()?.to_string();
let (message_id, timestamp) =
messages::insert_dm(&state.db, sender_id, recipient_id, &ciphertext, &nonce).await?;
let msg = ServerMessage::DirectMessage {
message_id,
sender_id,
sender_username,
ciphertext,
nonce,
timestamp,
};
// Deliver to recipient and to sender's other sessions (multi-client sync).
state.send_to_user(recipient_id, &msg).await;
state.send_to_user(sender_id, &msg).await;
Ok(())
}
pub async fn handle_dm_history(
session: &Session,
state: &Arc<AppState>,
peer_id: Uuid,
before_message_id: Option<Uuid>,
limit: u32,
) -> Result<()> {
let user_id = session.require_auth()?;
let history =
messages::fetch_dm_history(&state.db, user_id, peer_id, before_message_id, limit).await?;
session.send(ServerMessage::DmHistory { peer_id, messages: history })
}

View File

@@ -0,0 +1,4 @@
pub mod auth;
pub mod channels;
pub mod dms;
pub mod roles;

View File

@@ -0,0 +1,87 @@
use std::sync::Arc;
use backchannel_common::protocol::ServerMessage;
use backchannel_common::types::{has_permission, PermissionFlags};
use uuid::Uuid;
use crate::db::{keys, roles};
use crate::error::{Result, ServerError};
use crate::state::{AppState, ChannelBroadcast};
use crate::ws::session::Session;
pub async fn handle_create(
session: &Session,
state: &Arc<AppState>,
name: String,
permissions: u64,
) -> Result<()> {
let user_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, user_id).await?;
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
return Err(ServerError::Forbidden);
}
let role_id = roles::create(&state.db, &name, permissions).await?;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::RoleCreated { role_id, name, permissions },
});
Ok(())
}
pub async fn handle_assign(
session: &Session,
state: &Arc<AppState>,
user_id: Uuid,
role_id: Uuid,
) -> Result<()> {
let caller_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, caller_id).await?;
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
return Err(ServerError::Forbidden);
}
roles::find_by_id(&state.db, role_id)
.await?
.ok_or_else(|| ServerError::NotFound("Role not found".into()))?;
roles::assign(&state.db, user_id, role_id).await?;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::RoleAssigned { user_id, role_id },
});
Ok(())
}
pub async fn handle_revoke(
session: &Session,
state: &Arc<AppState>,
user_id: Uuid,
role_id: Uuid,
) -> Result<()> {
let caller_id = session.require_auth()?;
let perms = roles::get_user_permissions(&state.db, caller_id).await?;
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
return Err(ServerError::Forbidden);
}
roles::revoke(&state.db, user_id, role_id).await?;
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::RoleRevoked { user_id, role_id },
});
Ok(())
}
pub async fn handle_query_key(
session: &Session,
state: &Arc<AppState>,
user_id: Uuid,
) -> Result<()> {
session.require_auth()?;
let pubkey = keys::get_identity_key(&state.db, user_id).await?;
session.send(ServerMessage::IdentityKeyResponse { user_id, identity_pubkey: pubkey })
}

View File

@@ -0,0 +1,3 @@
mod server;
pub use server::serve;

View File

@@ -0,0 +1,65 @@
use anyhow::{Context, Result};
use axum::body::Body;
use axum::extract::Path;
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use mime_guess::from_path;
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../backchannel-web/dist"]
struct WebAssets;
pub async fn serve(bind_addr: &str) -> Result<()> {
let app = Router::new()
.route("/", get(index))
.route("/*path", get(asset_or_index));
let listener = tokio::net::TcpListener::bind(bind_addr)
.await
.with_context(|| format!("Failed to bind HTTP server to {bind_addr}"))?;
tracing::info!("BackChannel web UI listening on http://{bind_addr}");
axum::serve(listener, app).await.context("HTTP server failed")
}
async fn index() -> Response {
render_asset("index.html")
}
async fn asset_or_index(Path(path): Path<String>) -> Response {
if path.is_empty() {
return render_asset("index.html");
}
render_asset(&path)
}
fn render_asset(path: &str) -> Response {
let canonical = path.trim_start_matches('/');
if let Some(content) = WebAssets::get(canonical) {
return build_response(canonical, content.data.as_ref(), StatusCode::OK);
}
if let Some(index) = WebAssets::get("index.html") {
return build_response("index.html", index.data.as_ref(), StatusCode::OK);
}
(StatusCode::NOT_FOUND, "Web UI asset not found").into_response()
}
fn build_response(path: &str, body: &[u8], status: StatusCode) -> Response {
let mime = from_path(path).first_or_octet_stream();
let mut response = Response::new(Body::from(body.to_vec()));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static("application/octet-stream")),
);
response
}

View File

@@ -0,0 +1,62 @@
mod auth;
mod config;
mod db;
mod error;
mod handlers;
mod http;
mod state;
mod ws;
use std::sync::Arc;
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use tokio::sync::{broadcast, RwLock, Mutex};
use state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "backchannel_server=debug,info".into()),
)
.init();
let config = config::ServerConfig::from_env()?;
let opts = SqliteConnectOptions::from_str(&config.db_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await?;
sqlx::migrate!("./src/db/migrations").run(&pool).await?;
tracing::info!("Database migrations applied");
let (bc_tx, _) = broadcast::channel(1024);
let state = Arc::new(AppState {
sessions: RwLock::new(std::collections::HashMap::new()),
user_sessions: RwLock::new(std::collections::HashMap::new()),
pending_key_exchanges: Mutex::new(std::collections::HashMap::new()),
channel_broadcast: bc_tx,
db: pool,
jwt_secret: config.jwt_secret,
});
tokio::try_join!(
ws::listen(
Arc::clone(&state),
&config.bind_addr,
config.tls_enabled,
config.tls_cert_path,
config.tls_key_path,
),
http::serve(&config.http_bind_addr)
)?;
Ok(())
}

View File

@@ -0,0 +1,123 @@
use std::collections::HashMap;
use std::sync::Arc;
use backchannel_common::protocol::ServerMessage;
use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use uuid::Uuid;
/// A handle to send WebSocket frames directly to one connection.
pub type WsSender = mpsc::UnboundedSender<WsMessage>;
/// State for a single authenticated WebSocket connection.
pub struct ConnectedSession {
pub user_id: Uuid,
pub username: String,
/// Sending on this channel pushes a frame to the client's write task.
pub tx: WsSender,
}
/// A channel event broadcast to all connected clients.
///
/// Channel messages are fanned out via a `tokio::sync::broadcast` channel
/// so that the handler never has to hold a lock while iterating sessions.
#[derive(Clone, Debug)]
pub struct ChannelBroadcast {
pub message: ServerMessage,
}
/// The shared application state. Wrapped in `Arc` and passed to every task.
pub struct AppState {
/// All live WebSocket sessions, keyed by a per-connection UUID.
/// One user may hold multiple connections.
pub sessions: RwLock<HashMap<Uuid, ConnectedSession>>,
/// Reverse index: user_id → set of connection UUIDs.
/// Allows sending to all clients of a specific user (e.g. for DMs).
pub user_sessions: RwLock<HashMap<Uuid, Vec<Uuid>>>,
/// In-flight DM key-exchange requests.
/// Key: (initiator_user_id, recipient_user_id)
/// Value: initiator's base64 X25519 ephemeral public key.
/// Cleared once the recipient accepts the exchange.
pub pending_key_exchanges: Mutex<HashMap<(Uuid, Uuid), String>>,
/// Server-wide broadcast for channel events. Each connection handler
/// calls `.subscribe()` to get its own receiver.
pub channel_broadcast: broadcast::Sender<ChannelBroadcast>,
/// SQLite connection pool.
pub db: sqlx::SqlitePool,
/// Bytes used as the HMAC secret for JWT signing.
pub jwt_secret: Vec<u8>,
}
impl AppState {
/// Look up all active sender handles for a given user ID.
/// Used when routing DMs or presence events to a specific user.
pub async fn senders_for_user(&self, user_id: Uuid) -> Vec<WsSender> {
let sessions = self.sessions.read().await;
let user_sessions = self.user_sessions.read().await;
user_sessions
.get(&user_id)
.map(|conn_ids| {
conn_ids
.iter()
.filter_map(|id| sessions.get(id).map(|s| s.tx.clone()))
.collect()
})
.unwrap_or_default()
}
/// Send a `ServerMessage` to every connection belonging to `user_id`.
pub async fn send_to_user(&self, user_id: Uuid, msg: &ServerMessage) {
if let Ok(json) = serde_json::to_string(msg) {
for tx in self.senders_for_user(user_id).await {
let _ = tx.send(WsMessage::Text(json.clone()));
}
}
}
/// Register a new connection in the session maps.
pub async fn register_session(
self: &Arc<Self>,
conn_id: Uuid,
user_id: Uuid,
username: String,
tx: WsSender,
) {
let session = ConnectedSession { user_id, username, tx };
self.sessions.write().await.insert(conn_id, session);
self.user_sessions
.write()
.await
.entry(user_id)
.or_default()
.push(conn_id);
}
/// Remove a connection from the session maps.
pub async fn remove_session(self: &Arc<Self>, conn_id: Uuid) -> Option<ConnectedSession> {
let session = self.sessions.write().await.remove(&conn_id)?;
let mut user_sessions = self.user_sessions.write().await;
if let Some(ids) = user_sessions.get_mut(&session.user_id) {
ids.retain(|id| id != &conn_id);
if ids.is_empty() {
user_sessions.remove(&session.user_id);
}
}
Some(session)
}
/// Returns true if the given user has at least one active connection.
pub async fn is_user_online(&self, user_id: Uuid) -> bool {
self.user_sessions
.read()
.await
.get(&user_id)
.map(|v| !v.is_empty())
.unwrap_or(false)
}
}

View File

@@ -0,0 +1,179 @@
use std::io::BufReader;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, mpsc};
use tokio_tungstenite::{accept_async, tungstenite::Message as WsMessage, WebSocketStream};
use backchannel_common::protocol::{ClientMessage, ServerMessage};
use crate::state::{AppState, ChannelBroadcast};
use crate::ws::{router, session::Session};
/// Bind the TCP listener and accept connections in a loop.
pub async fn run(
state: Arc<AppState>,
bind_addr: &str,
tls_enabled: bool,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
) -> Result<()> {
let listener = TcpListener::bind(bind_addr)
.await
.with_context(|| format!("Failed to bind to {}", bind_addr))?;
tracing::info!("BackChannel server listening on ws{}://{}", if tls_enabled { "s" } else { "" }, bind_addr);
if tls_enabled {
let cert_path = tls_cert_path.expect("tls_cert_path required when TLS enabled");
let key_path = tls_key_path.expect("tls_key_path required when TLS enabled");
let acceptor = build_tls_acceptor(&cert_path, &key_path)?;
loop {
let (stream, addr) = listener.accept().await?;
let state = Arc::clone(&state);
let acceptor = acceptor.clone();
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(tls_stream) => match accept_async(tls_stream).await {
Ok(ws) => handle_ws(ws, state, addr).await,
Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e),
},
Err(e) => tracing::debug!("TLS handshake failed from {}: {}", addr, e),
}
});
}
} else {
loop {
let (stream, addr) = listener.accept().await?;
let state = Arc::clone(&state);
tokio::spawn(async move {
match accept_async(stream).await {
Ok(ws) => handle_ws(ws, state, addr).await,
Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e),
}
});
}
}
}
/// Per-connection WebSocket handler — generic over the underlying stream type
/// so it works with both plain TCP and TLS.
async fn handle_ws<S>(ws_stream: WebSocketStream<S>, state: Arc<AppState>, addr: SocketAddr)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
tracing::debug!("Connection established from {}", addr);
let (mut ws_sink, mut ws_source) = ws_stream.split();
// Per-connection mpsc: any task can push to this sender; the write task
// drains it to the WebSocket sink.
let (tx, mut rx) = mpsc::unbounded_channel::<WsMessage>();
let mut session = Session::new(tx);
let conn_id = session.conn_id;
// Subscribe to the server-wide channel broadcast before the loop starts.
let mut bc_rx = state.channel_broadcast.subscribe();
// Write task: merges direct messages (mpsc) + broadcast events → WS sink.
let write_task = tokio::spawn(async move {
loop {
tokio::select! {
msg = rx.recv() => {
match msg {
Some(m) => { if ws_sink.send(m).await.is_err() { break; } }
None => break,
}
}
event = bc_rx.recv() => {
match event {
Ok(ChannelBroadcast { message }) => {
if let Ok(json) = serde_json::to_string(&message) {
if ws_sink.send(WsMessage::Text(json)).await.is_err() { break; }
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("conn {} lagged by {} broadcast messages", conn_id, n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
});
// Read loop: receive, deserialize, dispatch.
while let Some(msg_result) = ws_source.next().await {
match msg_result {
Ok(WsMessage::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(client_msg) => {
if let Err(e) = router::route(client_msg, &mut session, &state).await {
let _ = session.send(ServerMessage::Error {
code: e.code(),
message: e.to_string(),
});
}
}
Err(_) => {
let _ = session.send(ServerMessage::Error {
code: 400,
message: "Invalid message format".into(),
});
}
}
}
Ok(WsMessage::Close(_)) | Err(_) => break,
Ok(_) => {} // Ignore Ping/Pong/Binary frames.
}
}
tracing::debug!("Connection closed from {}", addr);
// Clean up session maps and broadcast offline status.
if let Some(removed) = state.remove_session(conn_id).await {
if !state.is_user_online(removed.user_id).await {
let _ = state.channel_broadcast.send(ChannelBroadcast {
message: ServerMessage::UserOffline {
user_id: removed.user_id,
username: removed.username,
},
});
}
}
write_task.abort();
}
/// Build a `tokio_rustls::TlsAcceptor` from PEM cert and key files.
fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<tokio_rustls::TlsAcceptor> {
use rustls::ServerConfig;
use rustls_pemfile::{certs, private_key};
use std::fs::File;
let cert_file = File::open(cert_path)
.with_context(|| format!("Cannot open TLS cert: {}", cert_path))?;
let key_file = File::open(key_path)
.with_context(|| format!("Cannot open TLS key: {}", key_path))?;
let certs: Vec<_> = certs(&mut BufReader::new(cert_file))
.collect::<std::result::Result<_, _>>()
.context("Failed to parse TLS certificates")?;
let key = private_key(&mut BufReader::new(key_file))
.context("Failed to parse TLS private key")?
.context("No private key found in key file")?;
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.context("Invalid TLS certificate/key combination")?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
}

View File

@@ -0,0 +1,18 @@
pub mod handler;
pub mod router;
pub mod session;
use std::sync::Arc;
use anyhow::Result;
use crate::state::AppState;
/// Bind the TCP listener and accept WebSocket connections.
pub async fn listen(
state: Arc<AppState>,
bind_addr: &str,
tls_enabled: bool,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
) -> Result<()> {
handler::run(state, bind_addr, tls_enabled, tls_cert_path, tls_key_path).await
}

View File

@@ -0,0 +1,64 @@
use std::sync::Arc;
use backchannel_common::protocol::{ClientMessage, ServerMessage};
use crate::error::Result;
use crate::handlers;
use crate::state::AppState;
use crate::ws::session::Session;
/// Dispatch a `ClientMessage` to the appropriate handler.
pub async fn route(msg: ClientMessage, session: &mut Session, state: &Arc<AppState>) -> Result<()> {
use ClientMessage::*;
match msg {
Login { username, password, identity_pubkey } => {
handlers::auth::handle_login(session, state, username, password, identity_pubkey).await
}
Register { username, password, identity_pubkey } => {
handlers::auth::handle_register(session, state, username, password, identity_pubkey).await
}
ResumeSession { token } => {
handlers::auth::handle_resume(session, state, token).await
}
Logout => {
handlers::auth::handle_logout(session, state).await
}
SendChannelMessage { channel_id, content } => {
handlers::channels::handle_send(session, state, channel_id, content).await
}
FetchChannelHistory { channel_id, before_message_id, limit } => {
handlers::channels::handle_history(session, state, channel_id, before_message_id, limit).await
}
CreateChannel { name, topic } => {
handlers::channels::handle_create(session, state, name, topic).await
}
DeleteChannel { channel_id } => {
handlers::channels::handle_delete(session, state, channel_id).await
}
InitDmKeyExchange { recipient_id, sender_ephemeral_pubkey } => {
handlers::dms::handle_init_key_exchange(session, state, recipient_id, sender_ephemeral_pubkey).await
}
AcceptDmKeyExchange { initiator_id, recipient_ephemeral_pubkey } => {
handlers::dms::handle_accept_key_exchange(session, state, initiator_id, recipient_ephemeral_pubkey).await
}
SendDm { recipient_id, ciphertext, nonce } => {
handlers::dms::handle_send_dm(session, state, recipient_id, ciphertext, nonce).await
}
FetchDmHistory { peer_id, before_message_id, limit } => {
handlers::dms::handle_dm_history(session, state, peer_id, before_message_id, limit).await
}
CreateRole { name, permissions } => {
handlers::roles::handle_create(session, state, name, permissions).await
}
AssignRole { user_id, role_id } => {
handlers::roles::handle_assign(session, state, user_id, role_id).await
}
RevokeRole { user_id, role_id } => {
handlers::roles::handle_revoke(session, state, user_id, role_id).await
}
QueryIdentityKey { user_id } => {
handlers::roles::handle_query_key(session, state, user_id).await
}
Ping => session.send(ServerMessage::Pong),
}
}

View File

@@ -0,0 +1,45 @@
use backchannel_common::protocol::ServerMessage;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use uuid::Uuid;
use crate::error::{Result, ServerError};
use crate::state::WsSender;
/// Per-connection mutable state, owned exclusively by the connection's read task.
pub struct Session {
pub conn_id: Uuid,
pub user_id: Option<Uuid>,
pub username: Option<String>,
/// JWT ID stored for revocation on logout.
pub jti: Option<String>,
/// Sender to the write task for this connection.
pub tx: WsSender,
}
impl Session {
pub fn new(tx: WsSender) -> Self {
Self {
conn_id: Uuid::new_v4(),
user_id: None,
username: None,
jti: None,
tx,
}
}
/// Serialize and send a `ServerMessage` to this connection's write task.
pub fn send(&self, msg: ServerMessage) -> Result<()> {
let json = serde_json::to_string(&msg)?;
self.tx
.send(WsMessage::Text(json))
.map_err(|_| ServerError::ConnectionClosed)
}
pub fn require_auth(&self) -> Result<Uuid> {
self.user_id.ok_or(ServerError::Unauthorized)
}
pub fn require_username(&self) -> Result<&str> {
self.username.as_deref().ok_or(ServerError::Unauthorized)
}
}

24
backchannel-web/README.md Normal file
View File

@@ -0,0 +1,24 @@
# BackChannel Web
React + TypeScript browser client for BackChannel.
## Development
```powershell
cd backchannel-web
npm install
npm run dev
```
Default WebSocket target in the app is `ws://<host>:7777` and can be changed from the UI.
## Build for Embedded Server
```powershell
cd backchannel-web
npm install
npm run build
```
The Rust server embeds files from `backchannel-web/dist` at compile time.
Rebuild the Rust server after producing a new web build.

View File

@@ -0,0 +1 @@
:root{color-scheme:light;--bg-0: #0d1b2a;--panel: #fffaf0;--text: #1d2d44;--accent: #e76f51;--accent-2: #2a9d8f;--line: #f4a261}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#415a77,var(--bg-0) 55%);font-family:Space Grotesk,Trebuchet MS,sans-serif;color:var(--text)}.layout{width:min(1400px,100vw);margin:0 auto;padding:20px;display:grid;gap:14px}.card{background:linear-gradient(145deg,#fffaf0,#fdf0d5);border:2px solid #e9c46a;border-radius:16px;box-shadow:0 8px 24px #0003}.topbar{padding:16px;display:flex;flex-wrap:wrap;justify-content:space-between;align-items:end;gap:12px}h1{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.06em;font-size:clamp(2rem,4vw,2.8rem)}h2{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.05em}p{margin:6px 0 0}.connection{display:flex;gap:8px;min-width:min(540px,100%)}.status{padding:10px 14px;font-weight:700;border-color:var(--accent-2)}.chat-shell{position:relative;padding-left:350px}.panel{padding:14px;display:grid;gap:10px}.chat-main{min-height:calc(100vh - 220px)}.auth-sidebar{position:fixed;left:20px;bottom:20px;width:320px;z-index:10}.switch{display:flex;gap:8px}.switch .active{background:var(--accent-2);color:#fff}input{width:100%;border:2px solid #264653;border-radius:10px;padding:8px 10px;font:inherit;background:#fff}form{display:grid;gap:8px}button{border:0;background:var(--accent);color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:700}.chat-main form{grid-template-columns:1fr auto;align-items:end}.send-button{padding:6px 10px;min-width:68px}button:disabled{opacity:.5;cursor:not-allowed}.meta{display:grid;gap:6px;font-size:.9rem}.log{background:#fff;border:1px solid var(--line);border-radius:10px;min-height:200px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;justify-content:flex-end;gap:6px}.chat-log{max-height:calc(100vh - 360px)}.line{display:grid;gap:2px;border-bottom:1px dashed #c8d4dd;padding-bottom:6px}time{font-size:.75rem;color:#526072}@media (max-width: 900px){.chat-shell{padding-left:0}.auth-sidebar{position:static;width:100%;margin-top:14px}.chat-main{min-height:auto}.chat-log{max-height:50vh}.connection{min-width:0;width:100%;flex-direction:column}}

File diff suppressed because one or more lines are too long

13
backchannel-web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BackChannel</title>
<script type="module" crossorigin src="/assets/index-DEokFOhy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3Y0u3Sh.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BackChannel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1768
backchannel-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "backchannel-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.8.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}

236
backchannel-web/src/App.tsx Normal file
View File

@@ -0,0 +1,236 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { getOrCreateIdentityKey } from "./crypto";
import { ClientMessage, GENERAL_CHANNEL_ID, ServerMessage } from "./protocol";
type ChatLine = { id: string; by: string; text: string; at: string };
function parseServerMessage(raw: unknown): ServerMessage | null {
if (!raw || typeof raw !== "object") {
return null;
}
const candidate = raw as { type?: unknown };
if (typeof candidate.type !== "string") {
return null;
}
return raw as ServerMessage;
}
function defaultWsUrl(): string {
const host = window.location.hostname || "127.0.0.1";
return `ws://${host}:7777`;
}
export function App() {
const [wsUrl, setWsUrl] = useState(() => localStorage.getItem("bc.ws_url") ?? defaultWsUrl());
const [socketState, setSocketState] = useState<"offline" | "connecting" | "online">("offline");
const [authMode, setAuthMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [me, setMe] = useState<{ id: string; username: string } | null>(null);
const [channelInput, setChannelInput] = useState("");
const [channelLines, setChannelLines] = useState<ChatLine[]>([]);
const [status, setStatus] = useState("Not connected");
const wsRef = useRef<WebSocket | null>(null);
const chatLogRef = useRef<HTMLDivElement | null>(null);
const ready = useMemo(() => socketState === "online", [socketState]);
useEffect(() => {
if (chatLogRef.current) {
chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight;
}
}, [channelLines]);
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
}
function send(message: ClientMessage) {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
setStatus("WebSocket is not connected");
return;
}
ws.send(JSON.stringify(message));
}
function connect() {
if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) {
return;
}
setSocketState("connecting");
setStatus("Connecting...");
localStorage.setItem("bc.ws_url", wsUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setSocketState("online");
setStatus("Connected");
const token = localStorage.getItem("bc.session_token");
if (token) {
send({ type: "resume_session", token });
} else {
setStatus("Connected. Please login or register.");
}
};
ws.onclose = () => {
setSocketState("offline");
setStatus("Disconnected");
wsRef.current = null;
};
ws.onerror = () => {
setStatus("WebSocket error");
};
ws.onmessage = (event) => {
const msg = parseServerMessage(JSON.parse(event.data));
if (!msg) {
setStatus("Invalid server message");
return;
}
handleServer(msg);
};
}
function handleServer(msg: ServerMessage) {
switch (msg.type) {
case "auth_success": {
localStorage.setItem("bc.session_token", msg.session_token);
setMe({ id: msg.user_id, username: msg.username });
setStatus(`Authenticated as ${msg.username}`);
send({ type: "fetch_channel_history", channel_id: GENERAL_CHANNEL_ID, before_message_id: null, limit: 100 });
return;
}
case "auth_error": {
setStatus(`Auth error: ${msg.reason}`);
return;
}
case "channel_history": {
setChannelLines(
[...msg.messages]
.reverse()
.map((m) => ({ id: m.message_id, by: m.author_username, text: m.content, at: m.timestamp })),
);
return;
}
case "channel_message": {
if (msg.channel_id === GENERAL_CHANNEL_ID) {
setChannelLines((prev) => [
...prev,
{ id: msg.message_id, by: msg.author_username, text: msg.content, at: msg.timestamp },
]);
}
return;
}
case "error": {
setStatus(`Error ${msg.code}: ${msg.message}`);
return;
}
default:
return;
}
}
function submitAuth(event: FormEvent) {
event.preventDefault();
const identity = getOrCreateIdentityKey();
if (authMode === "login") {
send({ type: "login", username, password, identity_pubkey: identity });
return;
}
send({ type: "register", username, password, identity_pubkey: identity });
}
function submitChannel(event: FormEvent) {
event.preventDefault();
if (!channelInput.trim()) {
return;
}
send({ type: "send_channel_message", channel_id: GENERAL_CHANNEL_ID, content: channelInput.trim() });
setChannelInput("");
}
function logout() {
send({ type: "logout" });
localStorage.removeItem("bc.session_token");
setMe(null);
setStatus("Logged out");
}
return (
<main className="layout">
<section className="card topbar">
<div>
<h1>BackChannel</h1>
<p>General chat room.</p>
</div>
<div className="connection">
<input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://127.0.0.1:7777" />
<button onClick={connect} disabled={ready}>Connect</button>
</div>
</section>
<section className="card status">{status}</section>
<section className="chat-shell">
<article className="card panel chat-main">
<h2>#general</h2>
<form onSubmit={submitChannel}>
<input value={channelInput} onChange={(e) => setChannelInput(e.target.value)} placeholder="message #general" />
<button className="send-button" type="submit" disabled={!ready}>Send</button>
</form>
<div ref={chatLogRef} className="log chat-log">
{channelLines.map((line) => (
<div key={line.id} className="line">
<b>{line.by}</b>
<span>{line.text}</span>
<time>{formatTime(line.at)}</time>
</div>
))}
</div>
</article>
<aside className="card panel auth-sidebar">
<h2>Auth</h2>
<div className="switch">
<button className={authMode === "login" ? "active" : ""} onClick={() => setAuthMode("login")}>Login</button>
<button className={authMode === "register" ? "active" : ""} onClick={() => setAuthMode("register")}>Register</button>
</div>
<form onSubmit={submitAuth}>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="password" type="password" />
<button type="submit" disabled={!ready}>{authMode}</button>
</form>
<div className="meta">
<div>User: {me?.username ?? "(not authenticated)"}</div>
<div>ID: {me?.id ?? "-"}</div>
<button onClick={logout} disabled={!ready}>Logout</button>
</div>
</aside>
</section>
</main>
);
}

View File

@@ -0,0 +1,69 @@
import { chacha20poly1305 } from "@noble/ciphers/chacha";
import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { randomBytes } from "@noble/hashes/utils";
const HKDF_SALT = new TextEncoder().encode("backchannel-dm-salt-v1");
const HKDF_INFO = new TextEncoder().encode("backchannel-dm-key-v1");
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export function bytesToBase64(input: Uint8Array): string {
let bin = "";
for (const b of input) {
bin += String.fromCharCode(b);
}
return btoa(bin);
}
export function base64ToBytes(input: string): Uint8Array {
const bin = atob(input);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) {
bytes[i] = bin.charCodeAt(i);
}
return bytes;
}
export function getOrCreateIdentityKey(): string {
const current = localStorage.getItem("bc.identity_key");
if (current) {
return current;
}
const key = bytesToBase64(randomBytes(32));
localStorage.setItem("bc.identity_key", key);
return key;
}
export function createEphemeralKeypair(): { privateKey: Uint8Array; publicKeyB64: string } {
const privateKey = x25519.utils.randomPrivateKey();
const publicKey = x25519.getPublicKey(privateKey);
return { privateKey, publicKeyB64: bytesToBase64(publicKey) };
}
export function deriveDmKey(privateKey: Uint8Array, theirPublicKeyB64: string): Uint8Array {
const theirPublicKey = base64ToBytes(theirPublicKeyB64);
const sharedSecret = x25519.getSharedSecret(privateKey, theirPublicKey);
return hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);
}
export function encryptDm(key: Uint8Array, plaintext: string): { nonce: string; ciphertext: string } {
const nonceBytes = randomBytes(12);
const cipher = chacha20poly1305(key, nonceBytes);
const ciphertext = cipher.encrypt(encoder.encode(plaintext));
return {
nonce: bytesToBase64(nonceBytes),
ciphertext: bytesToBase64(ciphertext),
};
}
export function decryptDm(key: Uint8Array, nonceB64: string, ciphertextB64: string): string {
const nonceBytes = base64ToBytes(nonceB64);
const ciphertext = base64ToBytes(ciphertextB64);
const cipher = chacha20poly1305(key, nonceBytes);
const plaintext = cipher.decrypt(ciphertext);
return decoder.decode(plaintext);
}

View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,72 @@
export const GENERAL_CHANNEL_ID = "00000000-0000-0000-0000-000000000010";
export type ClientMessage =
| { type: "login"; username: string; password: string; identity_pubkey: string }
| { type: "register"; username: string; password: string; identity_pubkey: string }
| { type: "resume_session"; token: string }
| { type: "logout" }
| { type: "send_channel_message"; channel_id: string; content: string }
| { type: "fetch_channel_history"; channel_id: string; before_message_id: string | null; limit: number }
| { type: "init_dm_key_exchange"; recipient_id: string; sender_ephemeral_pubkey: string }
| { type: "accept_dm_key_exchange"; initiator_id: string; recipient_ephemeral_pubkey: string }
| { type: "send_dm"; recipient_id: string; ciphertext: string; nonce: string }
| { type: "fetch_dm_history"; peer_id: string; before_message_id: string | null; limit: number }
| { type: "ping" };
export type ServerMessage =
| { type: "auth_success"; user_id: string; username: string; session_token: string }
| { type: "auth_error"; reason: string }
| {
type: "channel_message";
message_id: string;
channel_id: string;
author_id: string;
author_username: string;
content: string;
timestamp: string;
}
| {
type: "channel_history";
channel_id: string;
messages: Array<{
message_id: string;
author_id: string;
author_username: string;
content: string;
timestamp: string;
}>;
}
| {
type: "dm_key_exchange_request";
initiator_id: string;
initiator_username: string;
sender_ephemeral_pubkey: string;
}
| {
type: "dm_key_exchange_response";
recipient_id: string;
recipient_username: string;
recipient_ephemeral_pubkey: string;
}
| {
type: "direct_message";
message_id: string;
sender_id: string;
sender_username: string;
ciphertext: string;
nonce: string;
timestamp: string;
}
| {
type: "dm_history";
peer_id: string;
messages: Array<{
message_id: string;
sender_id: string;
ciphertext: string;
nonce: string;
timestamp: string;
}>;
}
| { type: "error"; code: number; message: string }
| { type: "pong" };

View File

@@ -0,0 +1,207 @@
:root {
color-scheme: light;
--bg-0: #0d1b2a;
--panel: #fffaf0;
--text: #1d2d44;
--accent: #e76f51;
--accent-2: #2a9d8f;
--line: #f4a261;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #415a77, var(--bg-0) 55%);
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
color: var(--text);
}
.layout {
width: min(1400px, 100vw);
margin: 0 auto;
padding: 20px;
display: grid;
gap: 14px;
}
.card {
background: linear-gradient(145deg, #fffaf0, #fdf0d5);
border: 2px solid #e9c46a;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.topbar {
padding: 16px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: end;
gap: 12px;
}
h1 {
margin: 0;
font-family: "Bebas Neue", "Impact", sans-serif;
letter-spacing: 0.06em;
font-size: clamp(2rem, 4vw, 2.8rem);
}
h2 {
margin: 0;
font-family: "Bebas Neue", "Impact", sans-serif;
letter-spacing: 0.05em;
}
p {
margin: 6px 0 0;
}
.connection {
display: flex;
gap: 8px;
min-width: min(540px, 100%);
}
.status {
padding: 10px 14px;
font-weight: 700;
border-color: var(--accent-2);
}
.chat-shell {
position: relative;
padding-left: 350px;
}
.panel {
padding: 14px;
display: grid;
gap: 10px;
}
.chat-main {
min-height: calc(100vh - 220px);
}
.auth-sidebar {
position: fixed;
left: 20px;
bottom: 20px;
width: 320px;
z-index: 10;
}
.switch {
display: flex;
gap: 8px;
}
.switch .active {
background: var(--accent-2);
color: white;
}
input {
width: 100%;
border: 2px solid #264653;
border-radius: 10px;
padding: 8px 10px;
font: inherit;
background: #fff;
}
form {
display: grid;
gap: 8px;
}
button {
border: 0;
background: var(--accent);
color: #fff;
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
font-weight: 700;
}
.chat-main form {
grid-template-columns: 1fr auto;
align-items: end;
}
.send-button {
padding: 6px 10px;
min-width: 68px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.log {
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
min-height: 200px;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
}
.chat-log {
max-height: calc(100vh - 360px);
}
.line {
display: grid;
gap: 2px;
border-bottom: 1px dashed #c8d4dd;
padding-bottom: 6px;
}
time {
font-size: 0.75rem;
color: #526072;
}
@media (max-width: 900px) {
.chat-shell {
padding-left: 0;
}
.auth-sidebar {
position: static;
width: 100%;
margin-top: 14px;
}
.chat-main {
min-height: auto;
}
.chat-log {
max-height: 50vh;
}
.connection {
min-width: 0;
width: 100%;
flex-direction: column;
}
}

1
backchannel-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/crypto.ts","./src/main.tsx","./src/protocol.ts","./src/vite-env.d.ts"],"version":"5.9.3"}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});