fix: WASM double-X3DH bug, federated aliases, deploy tooling
WASM fix (critical):
- encrypt_key_exchange_with_id was calling x3dh::initiate a second time,
generating a new ephemeral key that didn't match the ratchet — receiver
always failed to decrypt. Now stores X3DH result from initiate() and
reuses it. Added 2 protocol tests confirming the fix + the bug.
- Bumped service worker cache to wz-v2 to force browsers to re-fetch.
- Disabled wasm-opt for Hetzner builds (libc compat issue).
Federation — alias support:
- resolve_alias falls back to federation peer if not found locally
- register_alias checks peer server before allowing — globally unique aliases
- Added resolve_remote_alias() and is_alias_taken_remote() to FederationHandle
Federation — key proxy fix:
- Remote bundles no longer cached locally (stale cache caused decrypt failures)
- Local vs remote determined by device: prefix in keys DB
Client fixes:
- Self-messaging blocked ("Cannot send messages to yourself")
- /peer <self> blocked
- last_dm_peer never set to self
- /r <message> sends reply inline (switches peer + sends in one command)
Deploy tooling:
- scripts/build-linux.sh with --ship (build + deploy + destroy)
- --update-all, --status, --logs commands
- WASM rebuilt on Hetzner VM before server binary
- deploy/ directory: systemd service, federation configs, setup script
- Journald log cap (50MB, 7-day retention)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,8 @@ pub struct FederationHandle {
|
||||
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||
/// Channel to send messages over the outgoing WS to the peer.
|
||||
pub outgoing: FederationSender,
|
||||
/// HTTP client for one-shot requests (key fetch, etc.)
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl FederationHandle {
|
||||
@@ -72,10 +74,15 @@ impl FederationHandle {
|
||||
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||
config.peer.id.clone(),
|
||||
)));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
FederationHandle {
|
||||
config,
|
||||
remote_presence,
|
||||
outgoing: Arc::new(Mutex::new(None)),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +103,41 @@ impl FederationHandle {
|
||||
self.send_json(msg).await
|
||||
}
|
||||
|
||||
/// Fetch a pre-key bundle from the peer server (HTTP GET fallback).
|
||||
/// Used when a local key lookup fails and the fingerprint is on the remote.
|
||||
pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option<Vec<u8>> {
|
||||
let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
let bundle_b64 = data.get("bundle")?.as_str()?;
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok()
|
||||
}
|
||||
|
||||
/// Resolve an alias on the peer server.
|
||||
/// Returns Some(fingerprint) if the peer knows this alias.
|
||||
pub async fn resolve_remote_alias(&self, alias: &str) -> Option<String> {
|
||||
let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
// Check for error (alias not found on peer)
|
||||
if data.get("error").is_some() {
|
||||
return None;
|
||||
}
|
||||
data.get("fingerprint").and_then(|v| v.as_str()).map(String::from)
|
||||
}
|
||||
|
||||
/// Check if an alias is already taken on the peer server.
|
||||
/// Returns true if the alias exists on the peer (taken).
|
||||
pub async fn is_alias_taken_remote(&self, alias: &str) -> bool {
|
||||
self.resolve_remote_alias(alias).await.is_some()
|
||||
}
|
||||
|
||||
/// Push local presence to peer via the persistent WS.
|
||||
pub async fn push_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||
let msg = serde_json::json!({
|
||||
@@ -171,10 +213,42 @@ pub async fn outgoing_ws_loop(
|
||||
};
|
||||
let _ = handle.push_presence(fps).await;
|
||||
|
||||
// Spawn task to forward outgoing channel to WS
|
||||
// Spawn task to forward outgoing channel + periodic ping to WS
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = out_rx.recv().await {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() {
|
||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = out_rx.recv() => {
|
||||
match msg {
|
||||
Some(text) => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = ping_interval.tick() => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn task to periodically re-push presence
|
||||
let presence_handle = handle.clone();
|
||||
let presence_conns = state.connections.clone();
|
||||
let presence_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let fps: Vec<String> = {
|
||||
let conns = presence_conns.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
if !presence_handle.push_presence(fps).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -182,13 +256,19 @@ pub async fn outgoing_ws_loop(
|
||||
|
||||
// Read incoming messages from peer
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
|
||||
handle_incoming_federation_msg(&text, &handle, &state).await;
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
handle_incoming_federation_msg(&text, &handle, &state).await;
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection lost
|
||||
send_task.abort();
|
||||
presence_task.abort();
|
||||
{
|
||||
let mut guard = handle.outgoing.lock().await;
|
||||
*guard = None;
|
||||
|
||||
@@ -152,6 +152,13 @@ async fn register_alias(
|
||||
delete_alias_record(&state.db.aliases, &existing)?;
|
||||
}
|
||||
|
||||
// Check if alias is taken on federation peer (globally unique)
|
||||
if let Some(ref federation) = state.federation {
|
||||
if federation.is_alias_taken_remote(&alias).await {
|
||||
return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" })));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old alias for this fingerprint (one alias per person)
|
||||
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
|
||||
@@ -292,7 +299,20 @@ async fn resolve_alias(
|
||||
})))
|
||||
}
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||
None => {
|
||||
// Try federation peer
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(fp) = federation.resolve_remote_alias(&alias).await {
|
||||
tracing::info!("Alias @{} resolved via federation: {}", alias, fp);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"federated": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "alias not found" })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
@@ -85,9 +85,26 @@ async fn get_bundle(
|
||||
.collect();
|
||||
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
|
||||
|
||||
// Check if this fingerprint registered locally (has a device: entry)
|
||||
let device_prefix = format!("device:{}:", key);
|
||||
let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some();
|
||||
|
||||
// For remote clients, always proxy from the federation peer (bundles may change)
|
||||
if !is_local {
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await {
|
||||
tracing::info!("get_bundle: PROXIED from federation peer for {}", key);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.db.keys.get(key.as_bytes()) {
|
||||
Ok(Some(data)) => {
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key);
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
||||
@@ -130,7 +147,7 @@ struct OtpkEntry {
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -71,7 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
||||
|
||||
async fn service_worker() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
||||
const CACHE = 'wz-v1';
|
||||
const CACHE = 'wz-v2';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
|
||||
Reference in New Issue
Block a user