TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)
Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS
Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)
Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs
Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb
WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping
Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21
WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.5 KiB
Rust
171 lines
5.5 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::state::AppState;
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/keys/register", post(register_keys))
|
|
.route("/keys/replenish", post(replenish_otpks))
|
|
.route("/keys/list", get(list_keys))
|
|
.route("/keys/:fingerprint", get(get_bundle))
|
|
.route("/keys/:fingerprint/otpk-count", get(otpk_count))
|
|
.route("/keys/:fingerprint/devices", get(list_devices))
|
|
}
|
|
|
|
/// Debug endpoint: list all registered fingerprints.
|
|
async fn list_keys(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
let keys: Vec<String> = state
|
|
.db
|
|
.keys
|
|
.iter()
|
|
.filter_map(|item| {
|
|
item.ok()
|
|
.and_then(|(k, _)| String::from_utf8(k.to_vec()).ok())
|
|
})
|
|
.collect();
|
|
tracing::info!("Listed {} registered keys", keys.len());
|
|
Json(serde_json::json!({ "keys": keys, "count": keys.len() }))
|
|
}
|
|
|
|
/// Normalize fingerprint: strip colons, lowercase.
|
|
fn normalize_fp(fp: &str) -> String {
|
|
fp.chars()
|
|
.filter(|c| c.is_ascii_hexdigit())
|
|
.collect::<String>()
|
|
.to_lowercase()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RegisterRequest {
|
|
fingerprint: String,
|
|
#[serde(default)]
|
|
device_id: Option<String>,
|
|
bundle: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RegisterResponse {
|
|
ok: bool,
|
|
}
|
|
|
|
async fn register_keys(
|
|
_auth: crate::auth_middleware::AuthFingerprint,
|
|
State(state): State<AppState>,
|
|
Json(req): Json<RegisterRequest>,
|
|
) -> Json<RegisterResponse> {
|
|
let fp = normalize_fp(&req.fingerprint);
|
|
let device_id = req.device_id.unwrap_or_else(|| "default".to_string());
|
|
|
|
// Store bundle keyed by fingerprint (primary, used for lookup)
|
|
let _ = state.db.keys.insert(fp.as_bytes(), req.bundle.clone());
|
|
|
|
// Also store per-device: device:<fp>:<device_id> → bundle
|
|
let device_key = format!("device:{}:{}", fp, device_id);
|
|
let _ = state.db.keys.insert(device_key.as_bytes(), req.bundle);
|
|
|
|
tracing::info!("Registered bundle for {} (device: {})", fp, device_id);
|
|
Json(RegisterResponse { ok: true })
|
|
}
|
|
|
|
async fn get_bundle(
|
|
State(state): State<AppState>,
|
|
Path(fingerprint): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
|
let key = normalize_fp(&fingerprint);
|
|
tracing::info!("get_bundle: raw path='{}', normalized='{}'", fingerprint, key);
|
|
|
|
// Debug: list what's in the DB
|
|
let all_keys: Vec<String> = state.db.keys.iter()
|
|
.filter_map(|r| r.ok().and_then(|(k, _)| String::from_utf8(k.to_vec()).ok()))
|
|
.collect();
|
|
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
|
|
|
|
match state.db.keys.get(key.as_bytes()) {
|
|
Ok(Some(data)) => {
|
|
tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key);
|
|
Ok(Json(serde_json::json!({
|
|
"fingerprint": fingerprint,
|
|
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
|
})))
|
|
}
|
|
Ok(None) => {
|
|
tracing::warn!("get_bundle: NOT FOUND for key '{}'", key);
|
|
Err(axum::http::StatusCode::NOT_FOUND)
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("get_bundle: DB error: {}", e);
|
|
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check how many one-time pre-keys remain for a fingerprint.
|
|
async fn otpk_count(
|
|
State(state): State<AppState>,
|
|
Path(fingerprint): Path<String>,
|
|
) -> Json<serde_json::Value> {
|
|
let fp = normalize_fp(&fingerprint);
|
|
let prefix = format!("otpk:{}:", fp);
|
|
let count = state.db.keys.scan_prefix(prefix.as_bytes()).count();
|
|
Json(serde_json::json!({ "fingerprint": fp, "otpk_count": count }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ReplenishRequest {
|
|
fingerprint: String,
|
|
/// One-time pre-keys: list of {id, public_key_hex}
|
|
otpks: Vec<OtpkEntry>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct OtpkEntry {
|
|
id: u32,
|
|
public_key: String, // hex-encoded 32-byte X25519 public key
|
|
}
|
|
|
|
/// Upload additional one-time pre-keys.
|
|
async fn replenish_otpks(
|
|
_auth: crate::auth_middleware::AuthFingerprint,
|
|
State(state): State<AppState>,
|
|
Json(req): Json<ReplenishRequest>,
|
|
) -> Json<serde_json::Value> {
|
|
let fp = normalize_fp(&req.fingerprint);
|
|
let mut stored = 0;
|
|
|
|
for otpk in &req.otpks {
|
|
let key = format!("otpk:{}:{}", fp, otpk.id);
|
|
let _ = state.db.keys.insert(key.as_bytes(), otpk.public_key.as_bytes());
|
|
stored += 1;
|
|
}
|
|
|
|
let prefix = format!("otpk:{}:", fp);
|
|
let total = state.db.keys.scan_prefix(prefix.as_bytes()).count();
|
|
|
|
tracing::info!("Replenished {} OTPKs for {} (total: {})", stored, fp, total);
|
|
Json(serde_json::json!({ "ok": true, "stored": stored, "total": total }))
|
|
}
|
|
|
|
/// List all registered devices for a fingerprint.
|
|
async fn list_devices(
|
|
State(state): State<AppState>,
|
|
Path(fingerprint): Path<String>,
|
|
) -> Json<serde_json::Value> {
|
|
let fp = normalize_fp(&fingerprint);
|
|
let prefix = format!("device:{}:", fp);
|
|
let devices: Vec<String> = state.db.keys.scan_prefix(prefix.as_bytes())
|
|
.filter_map(|item| {
|
|
item.ok().and_then(|(k, _)| {
|
|
let key_str = String::from_utf8_lossy(&k).to_string();
|
|
// key format: device:<fp>:<device_id>
|
|
key_str.rsplit(':').next().map(|s| s.to_string())
|
|
})
|
|
})
|
|
.collect();
|
|
Json(serde_json::json!({ "fingerprint": fp, "devices": devices, "count": devices.len() }))
|
|
}
|