Unified server binary (vnox-serverd) combining: - Gateway (TCP, auth, channels, sessions, SQLite) - Voice-node (UDP relay, Opus, jitter buffer) Standalone gateway (vnox-gateway) and voice-node (vnox-voice-node) preserved as sub-crates.
140 lines
4.6 KiB
Rust
140 lines
4.6 KiB
Rust
use anyhow::Result;
|
|
use tokio::net::TcpStream;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::{
|
|
domain::{auth, session},
|
|
net::io,
|
|
net::state::State,
|
|
proto::{self, AuthPayload, HelloPayload, PacketId, SessionCrypto, SessionPayload, to_payload},
|
|
};
|
|
|
|
const LNEX_VERSION: &str = "v1";
|
|
|
|
/// Run HELLO → AUTH → SESSION with X25519 ECDH key exchange.
|
|
///
|
|
/// Returns the session and the derived crypto context (encryption keys).
|
|
/// All subsequent packets must be encrypted with `crypto`.
|
|
pub async fn run(
|
|
stream: &mut TcpStream,
|
|
addr: std::net::SocketAddr,
|
|
state: &State,
|
|
seq: &mut u32,
|
|
) -> Result<(session::Session, SessionCrypto)> {
|
|
// Generate ephemeral X25519 keypair for forward secrecy
|
|
let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral();
|
|
let eph_pk_hex = hex::encode(eph_pk.as_bytes());
|
|
|
|
// HELLO — include server's ephemeral public key + privacy mode
|
|
let challenge = auth::new_challenge();
|
|
let private_mode = state.config.is_private();
|
|
let hello = HelloPayload {
|
|
lnex_version: LNEX_VERSION.into(),
|
|
server_pubkey: state.server_identity.pubkey_hex(),
|
|
challenge_nonce: hex::encode(challenge),
|
|
node_name: state.config.node.name.clone(),
|
|
server_eph_pubkey: eph_pk_hex,
|
|
private_mode,
|
|
};
|
|
io::send_packet(stream, PacketId::Hello, seq, &to_payload(&hello)).await?;
|
|
debug!("{addr} ← HELLO (eph key included)");
|
|
|
|
// AUTH — receive client's ephemeral public key
|
|
let (hdr, payload) = io::read_packet(stream).await?;
|
|
if PacketId::from_u16(hdr.packet_id) != Some(PacketId::Auth) {
|
|
io::send_error(
|
|
stream,
|
|
seq,
|
|
proto::ErrorCode::InvalidPacket,
|
|
"expected AUTH",
|
|
)
|
|
.await?;
|
|
return Err(anyhow::anyhow!("expected AUTH"));
|
|
}
|
|
|
|
let msg: AuthPayload = serde_json::from_slice(&payload)?;
|
|
debug!("{addr} → AUTH nick={}", msg.nickname);
|
|
|
|
if msg.lnex_version != LNEX_VERSION {
|
|
io::send_error(
|
|
stream,
|
|
seq,
|
|
proto::ErrorCode::VersionMismatch,
|
|
"unsupported version",
|
|
)
|
|
.await?;
|
|
return Err(anyhow::anyhow!("version mismatch"));
|
|
}
|
|
|
|
let pubkey =
|
|
hex_to_32(&msg.client_pubkey).ok_or_else(|| anyhow::anyhow!("bad client pubkey"))?;
|
|
let sig = hex_to_64(&msg.signature).ok_or_else(|| anyhow::anyhow!("bad signature"))?;
|
|
|
|
if let Err(e) = auth::verify_auth(&challenge, &pubkey, &sig) {
|
|
warn!("{addr} auth failed: {e}");
|
|
state.metrics.inc(&state.metrics.auth_failures);
|
|
io::send_error(
|
|
stream,
|
|
seq,
|
|
proto::ErrorCode::AuthFailed,
|
|
"invalid signature",
|
|
)
|
|
.await?;
|
|
return Err(anyhow::anyhow!("auth failed"));
|
|
}
|
|
|
|
if state.storage.is_banned(&msg.client_pubkey).await? {
|
|
state.metrics.inc(&state.metrics.auth_failures);
|
|
io::send_error(stream, seq, proto::ErrorCode::AuthFailed, "banned").await?;
|
|
return Err(anyhow::anyhow!("banned"));
|
|
}
|
|
|
|
state
|
|
.storage
|
|
.upsert_user(&msg.client_pubkey, &msg.nickname)
|
|
.await?;
|
|
|
|
// ECDH: compute shared secret from server's ephemeral sk + client's ephemeral pk
|
|
let client_eph_raw = hex_to_32(&msg.client_eph_pubkey)
|
|
.ok_or_else(|| anyhow::anyhow!("bad client eph pubkey"))?;
|
|
let client_eph_pk = x25519_dalek::PublicKey::from(client_eph_raw);
|
|
let shared_secret = SessionCrypto::ecdh(eph_sk, &client_eph_pk);
|
|
|
|
// SESSION
|
|
let sess = session::create(
|
|
&state.sessions,
|
|
msg.client_pubkey.clone(),
|
|
msg.nickname.clone(),
|
|
)
|
|
.await;
|
|
|
|
// Derive encryption keys from shared secret + session_id
|
|
let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id);
|
|
|
|
let timeout_secs = state.config.gateway.session_timeout.unwrap_or(600);
|
|
let expires_at = std::time::SystemTime::now()
|
|
.checked_add(std::time::Duration::from_secs(timeout_secs))
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let sp = SessionPayload {
|
|
session_id: sess.session_id.clone(),
|
|
token: sess.token.clone(),
|
|
expires_at,
|
|
};
|
|
io::send_packet(stream, PacketId::Session, seq, &to_payload(&sp)).await?;
|
|
info!(
|
|
"{addr} auth ok — nick={} sid={} encrypted",
|
|
sess.nickname,
|
|
&sess.session_id[..8]
|
|
);
|
|
|
|
Ok((sess, crypto))
|
|
}
|
|
|
|
fn hex_to_32(s: &str) -> Option<[u8; 32]> {
|
|
hex::decode(s).ok()?.try_into().ok()
|
|
}
|
|
fn hex_to_64(s: &str) -> Option<[u8; 64]> {
|
|
hex::decode(s).ok()?.try_into().ok()
|
|
}
|