Introduced AuthManager with signed nonce handling and long-term credential validation.

This commit is contained in:
ghost 2025-11-16 18:51:13 +01:00
parent c77e95afdd
commit 7169ed0d1e
7 changed files with 1216 additions and 951 deletions

View File

@ -8,20 +8,22 @@ Goals
- Start with a minimal, well-tested parsing/utility layer and an in-memory credential store interface that can be replaced later.
Current status
- UDP listener on 0.0.0.0:3478 (STUN/TURN) implemented.
- UDP listener on 0.0.0.0:3478 (STUN/TURN) with Allocate, CreatePermission, ChannelBind, and Send flows forwarding traffic via relay sockets.
- Long-term authentication with REALM/NONCE challenges and MESSAGE-INTEGRITY validation driven by `AuthManager`.
- STUN message parser + builder in `src/stun.rs`.
- CredentialStore trait + in-memory implementation in `src/auth.rs`.
- Minimal logic: on any STUN request, server replies with a 401 challenge (REALM + NONCE).
- Optional TLS listener (0.0.0.0:5349) mirrors the UDP path for `turns:` clients.
Design
- Modules
- `stun.rs` - STUN/TURN message parsing and builders.
- `auth.rs` - CredentialStore trait and an `InMemoryStore` impl. Use the trait to swap for DB-backed stores later.
- `main.rs` - Bootstraps UDP listener, parses requests, and emits challenges for auth.
- `stun.rs` STUN/TURN message parsing, MESSAGE-INTEGRITY helpers, and response builders.
- `auth.rs` `AuthManager` orchestrates nonce minting, realm checking, and key derivation using the pluggable `CredentialStore` (default: `InMemoryStore`).
- `alloc.rs` Relay allocation management with permission and channel tracking.
- `main.rs` / `tls.rs` Runtime wiring for UDP and TLS listeners using the shared authentication + allocation stack.
CredentialStore interface
- `CredentialStore` is an async trait with `get_password(username) -> Option<String>`.
- The default `InMemoryStore` is provided for tests and local dev. Swap in a production store by implementing the trait.
Authentication & credential store
- `CredentialStore` is an async trait with `get_password(username) -> Option<String>` used by `AuthManager`.
- `AuthManager` derives the RFC long-term key (`MD5(username:realm:password)`) and validates MESSAGE-INTEGRITY while issuing signed, timestamped nonces.
- The default `InMemoryStore` is provided for tests and local dev. Swap in a production store by implementing the trait and passing it to `AuthManager`.
How to build
@ -51,11 +53,10 @@ Security / Deployment
- Ensure UDP and TCP/TLS ports (3478/5349) are reachable from the internet when used as a public TURN server.
Auth caveat
- The current in-repo long-term auth implementation is intentionally minimal for the MVP and
uses legacy constructs (A1/MD5 derivation + HMAC-SHA1 MESSAGE-INTEGRITY). MD5 is not recommended
for new secure systems — this is present for RFC compatibility and testing only. We will replace
this with a secure credential workflow (ephemeral/REST credentials, PBKDF/KDF storage, or mTLS)
before any production deployment. See `src/auth.rs` for the current simple store and helpers.
- The current implementation intentionally keeps things simple: credentials live in-memory, A1 keys
are derived via MD5 for RFC compatibility, and nonces are signed with HMAC-SHA1. Replace these
pieces (Argon2-backed store, modern KDFs, nonce rotation) before production rollout. See
`src/auth.rs` for the pluggable surface.
Milestone 1 — Protocol Backlog
------------------------------
@ -154,11 +155,16 @@ Das Projekt kann eine JSON-Konfigdatei `appsettings.json` im Arbeitsverzeichnis
"username": "testuser",
"password": "secretpassword"
}
]
],
"auth": {
"realm": "niom-turn.local",
"nonce_secret": null,
"nonce_ttl_seconds": 300
}
}
```
Wenn `appsettings.json` vorhanden ist, verwendet der Server die `server.bind` Adresse und befüllt den anfänglichen Credential-Store aus dem `credentials`-Array. Falls die Datei fehlt, verwendet der Server die internen Defaults (Bind `0.0.0.0:3478` und Demo-Cred `testuser`).
Wenn `appsettings.json` vorhanden ist, verwendet der Server die `server.bind` Adresse, befüllt den Credential-Store aus dem `credentials`-Array und übernimmt zusätzlich Realm/Nonce-Einstellungen aus `auth`. Falls die Datei fehlt, verwendet der Server die internen Defaults (Bind `0.0.0.0:3478`, Demo-Cred `testuser`, Realm `niom-turn.local`).
Deployment & TLS / Long-term Auth roadmap
-----------------------------------------

View File

@ -9,5 +9,10 @@
"username": "testuser",
"password": "secretpassword"
}
]
],
"auth": {
"realm": "niom-turn.local",
"nonce_secret": null,
"nonce_ttl_seconds": 300
}
}

View File

@ -1,8 +1,16 @@
//! Authentication helpers and the in-memory credential store used for the MVP server.
//! Backlog: Argon2-backed storage, nonce lifecycle, and integration with persistent secrets.
use crate::config::AuthOptions;
use crate::constants::{ATTR_NONCE, ATTR_REALM, ATTR_USERNAME};
use crate::models::stun::StunMessage;
use crate::stun::{find_message_integrity, validate_message_integrity};
use crate::traits::CredentialStore;
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use sha1::Sha1;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Simple in-memory credential store for MVP
#[derive(Clone, Default)]
@ -32,6 +40,213 @@ impl CredentialStore for InMemoryStore {
}
}
/// Authentication settings resolved from configuration for runtime usage.
#[derive(Clone, Debug)]
pub struct AuthSettings {
pub realm: String,
pub nonce_secret: Vec<u8>,
pub nonce_ttl: Duration,
}
impl AuthSettings {
pub fn from_options(opts: &AuthOptions) -> Self {
let secret = opts
.nonce_secret
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Ensure TTL does not collapse to zero so challenges stay valid briefly.
let ttl = Duration::from_secs(opts.nonce_ttl_seconds.max(60));
Self {
realm: opts.realm.clone(),
nonce_secret: secret.into_bytes(),
nonce_ttl: ttl,
}
}
}
/// Result of validating authentication attributes on an incoming STUN/TURN request.
#[derive(Debug, Clone)]
pub enum AuthStatus {
Granted { username: String },
Challenge { nonce: String },
StaleNonce { nonce: String },
Reject { code: u16, reason: &'static str },
}
/// Orchestrates STUN/TURN long-term credential validation for the server.
pub struct AuthManager<S: CredentialStore + Clone> {
store: S,
settings: AuthSettings,
}
impl<S: CredentialStore + Clone> Clone for AuthManager<S> {
fn clone(&self) -> Self {
Self {
store: self.store.clone(),
settings: self.settings.clone(),
}
}
}
impl<S: CredentialStore + Clone> AuthManager<S> {
pub fn new(store: S, opts: &AuthOptions) -> Self {
Self {
store,
settings: AuthSettings::from_options(opts),
}
}
pub fn realm(&self) -> &str {
&self.settings.realm
}
/// Inspect a parsed STUN/TURN message and determine whether credentials are acceptable.
pub async fn authenticate(&self, msg: &StunMessage, peer: &SocketAddr) -> AuthStatus {
if find_message_integrity(msg).is_none() {
// Client has not yet computed MESSAGE-INTEGRITY; ask it to retry with credentials.
return AuthStatus::Challenge {
nonce: self.mint_nonce(peer),
};
}
let username = match self.attribute_utf8(msg, ATTR_USERNAME) {
Some(u) => u,
None => {
return AuthStatus::Challenge {
nonce: self.mint_nonce(peer),
}
}
};
let realm = match self.attribute_utf8(msg, ATTR_REALM) {
Some(r) => r,
None => {
return AuthStatus::Challenge {
nonce: self.mint_nonce(peer),
}
}
};
if realm != self.settings.realm {
return AuthStatus::Reject {
code: 400,
reason: "Realm Mismatch",
};
}
let nonce = match self.attribute_utf8(msg, ATTR_NONCE) {
Some(n) => n,
None => {
return AuthStatus::Challenge {
nonce: self.mint_nonce(peer),
}
}
};
match self.check_nonce(&nonce, peer) {
NonceValidation::Valid => {}
NonceValidation::Expired => {
return AuthStatus::StaleNonce {
nonce: self.mint_nonce(peer),
}
}
NonceValidation::Invalid => {
return AuthStatus::Challenge {
nonce: self.mint_nonce(peer),
}
}
}
let password = match self.store.get_password(&username).await {
Some(p) => p,
None => {
return AuthStatus::Reject {
code: 401,
reason: "Unknown User",
}
}
};
let key = self.derive_long_term_key(&username, &password);
if !validate_message_integrity(msg, &key) {
return AuthStatus::Reject {
code: 401,
reason: "Bad Credentials",
};
}
AuthStatus::Granted { username }
}
fn attribute_utf8(&self, msg: &StunMessage, attr_type: u16) -> Option<String> {
msg.attributes
.iter()
.find(|a| a.typ == attr_type)
.and_then(|attr| std::str::from_utf8(&attr.value).ok())
.map(|s| s.to_string())
}
fn derive_long_term_key(&self, username: &str, password: &str) -> Vec<u8> {
compute_a1_md5(username, &self.settings.realm, password)
}
pub fn mint_nonce(&self, peer: &SocketAddr) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs();
let payload = format!("{}|{}", now, peer.ip());
let sig = self.sign_payload(payload.as_bytes());
format!("{}:{}", now, sig)
}
fn check_nonce(&self, nonce: &str, peer: &SocketAddr) -> NonceValidation {
let mut parts = nonce.splitn(2, ':');
let ts_str = parts.next();
let sig_str = parts.next();
let (ts_str, sig_str) = match (ts_str, sig_str) {
(Some(ts), Some(sig)) => (ts, sig),
_ => return NonceValidation::Invalid,
};
let timestamp = match ts_str.parse::<u64>() {
Ok(t) => t,
Err(_) => return NonceValidation::Invalid,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs();
if now.saturating_sub(timestamp) > self.settings.nonce_ttl.as_secs() {
return NonceValidation::Expired;
}
let payload = format!("{}|{}", timestamp, peer.ip());
let expected = self.sign_payload(payload.as_bytes());
if expected == sig_str {
NonceValidation::Valid
} else {
NonceValidation::Invalid
}
}
fn sign_payload(&self, payload: &[u8]) -> String {
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(&self.settings.nonce_secret)
.expect("nonce secret to build hmac");
mac.update(payload);
let bytes = mac.finalize().into_bytes();
hex::encode(bytes)
}
}
enum NonceValidation {
Valid,
Expired,
Invalid,
}
/// Helper: compute MESSAGE-INTEGRITY (HMAC-SHA1 as bytes)
pub fn compute_hmac_sha1_bytes(key: &str, data: &[u8]) -> Vec<u8> {
use hmac::{Hmac, Mac};

View File

@ -3,12 +3,43 @@
use serde::Deserialize;
use std::path::Path;
fn default_realm() -> String {
"niom-turn.local".to_string()
}
fn default_nonce_ttl_seconds() -> u64 {
300
}
#[derive(Debug, Deserialize, Clone)]
pub struct CredentialEntry {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthOptions {
/// STUN/TURN realm advertised to clients when issuing challenges.
#[serde(default = "default_realm")]
pub realm: String,
/// Optional shared secret used to sign nonces; if omitted a random value is generated at runtime.
#[serde(default)]
pub nonce_secret: Option<String>,
/// Validity period for generated nonces in seconds.
#[serde(default = "default_nonce_ttl_seconds")]
pub nonce_ttl_seconds: u64,
}
impl Default for AuthOptions {
fn default() -> Self {
Self {
realm: default_realm(),
nonce_secret: None,
nonce_ttl_seconds: default_nonce_ttl_seconds(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerOptions {
/// Listen address, e.g. "0.0.0.0:3478"
@ -25,6 +56,9 @@ pub struct Config {
/// Initial credentials to populate the credential store
#[serde(default)]
pub credentials: Vec<CredentialEntry>,
/// Authentication behaviour advertised to clients.
#[serde(default)]
pub auth: AuthOptions,
}
impl Config {

View File

@ -7,14 +7,13 @@ use tracing::{error, info};
// Use the library crate's public modules instead of local `mod` declarations.
use niom_turn::alloc::AllocationManager;
use niom_turn::auth::InMemoryStore;
use niom_turn::config::Config;
use niom_turn::auth::{AuthManager, AuthStatus, InMemoryStore};
use niom_turn::config::{AuthOptions, Config};
use niom_turn::constants::*;
use niom_turn::stun::{
build_401_response, build_error_response, build_success_response, decode_xor_peer_address,
encode_xor_relayed_address, find_message_integrity, parse_message, validate_message_integrity,
encode_xor_relayed_address, parse_message,
};
use niom_turn::traits::CredentialStore;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@ -44,6 +43,7 @@ async fn main() -> anyhow::Result<()> {
username: "testuser".into(),
password: "secretpassword".into(),
}],
auth: AuthOptions::default(),
}
}
};
@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> {
creds.insert(&c.username, &c.password);
}
let auth = AuthManager::new(creds.clone(), &cfg.auth);
// Bind the UDP socket that receives STUN/TURN traffic from WebRTC clients.
let udp = UdpSocket::bind(bind_addr).await?;
let udp = Arc::new(udp);
@ -65,10 +67,10 @@ async fn main() -> anyhow::Result<()> {
// Spawn the asynchronous packet loop that handles all UDP requests.
let udp_clone = udp.clone();
let creds_clone = creds.clone();
let auth_clone = auth.clone();
let alloc_clone = alloc_mgr.clone();
tokio::spawn(async move {
if let Err(e) = udp_reader_loop(udp_clone, creds_clone, alloc_clone).await {
if let Err(e) = udp_reader_loop(udp_clone, auth_clone, alloc_clone).await {
error!("udp loop error: {:?}", e);
}
});
@ -76,7 +78,7 @@ async fn main() -> anyhow::Result<()> {
// Optionally start the TLS listener so `turns:` clients can connect via TCP/TLS.
if let (Some(cert), Some(key)) = (cfg.server.tls_cert.clone(), cfg.server.tls_key.clone()) {
let udp_for_tls = udp.clone();
let creds_for_tls = creds.clone();
let auth_for_tls = auth.clone();
let alloc_for_tls = alloc_mgr.clone();
tokio::spawn(async move {
if let Err(e) = niom_turn::tls::serve_tls(
@ -84,7 +86,7 @@ async fn main() -> anyhow::Result<()> {
&cert,
&key,
udp_for_tls,
creds_for_tls,
auth_for_tls,
alloc_for_tls,
)
.await
@ -102,7 +104,7 @@ async fn main() -> anyhow::Result<()> {
async fn udp_reader_loop(
udp: Arc<UdpSocket>,
creds: InMemoryStore,
auth: AuthManager<InMemoryStore>,
allocs: AllocationManager,
) -> anyhow::Result<()> {
let mut buf = vec![0u8; 1500];
@ -119,377 +121,346 @@ async fn udp_reader_loop(
msg.header.msg_type,
len
);
// Fast-path authenticated requests when MESSAGE-INTEGRITY can be validated.
if let Some(_mi_attr) = find_message_integrity(&msg) {
// For MVP we expect username attribute (USERNAME) to be present
let username_attr = msg.attributes.iter().find(|a| a.typ == ATTR_USERNAME);
if let Some(u) = username_attr {
if let Ok(username) = std::str::from_utf8(&u.value) {
// lookup password
let store = creds.clone();
let pw = store.get_password(username).await;
if let Some(password) = pw {
let valid = validate_message_integrity(&msg, &password);
if valid {
tracing::info!("MI valid for user {}", username);
// Handle authenticated Allocate to mint a relay binding for the client.
if msg.header.msg_type == METHOD_ALLOCATE {
match allocs.allocate_for(peer, udp.clone()).await {
Ok(relay_addr) => {
use bytes::BytesMut;
let mut out = BytesMut::new();
let success_type = msg.header.msg_type | CLASS_SUCCESS;
out.extend_from_slice(&success_type.to_be_bytes());
out.extend_from_slice(&0u16.to_be_bytes());
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
out.extend_from_slice(&msg.header.transaction_id);
// RFC: XOR-RELAYED-ADDRESS (0x0016)
let attr_val = encode_xor_relayed_address(
&relay_addr,
&msg.header.transaction_id,
);
out.extend_from_slice(
&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes(),
);
out.extend_from_slice(
&((attr_val.len() as u16).to_be_bytes()),
);
out.extend_from_slice(&attr_val);
while (out.len() % 4) != 0 {
out.extend_from_slice(&[0]);
}
let total_len = (out.len() - 20) as u16;
let len_bytes = total_len.to_be_bytes();
out[2] = len_bytes[0];
out[3] = len_bytes[1];
let vec_out = out.to_vec();
tracing::info!("sending allocate success (mi-valid) -> {} bytes hex={} ", vec_out.len(), hex::encode(&vec_out));
let _ = udp.send_to(&vec_out, &peer).await;
continue;
}
Err(e) => tracing::error!(
"allocate failed after MI valid: {:?}",
e
),
}
} else if msg.header.msg_type == METHOD_CREATE_PERMISSION {
// Permission updates extend the list of peer addresses an allocation may forward to.
if allocs.get_allocation(&peer).is_none() {
tracing::warn!(
"create-permission without allocation from {}",
peer
);
let resp = build_error_response(
&msg.header,
437,
"Allocation Mismatch",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
let requires_auth = matches!(
msg.header.msg_type,
METHOD_ALLOCATE
| METHOD_CREATE_PERMISSION
| METHOD_CHANNEL_BIND
| METHOD_SEND
| METHOD_REFRESH
);
let mut added = 0usize;
for attr in msg
.attributes
.iter()
.filter(|a| a.typ == ATTR_XOR_PEER_ADDRESS)
{
if let Some(peer_addr) = decode_xor_peer_address(
&attr.value,
&msg.header.transaction_id,
) {
match allocs.add_permission(peer, peer_addr) {
Ok(()) => {
tracing::info!(
"added permission for {} -> {}",
peer,
peer_addr
);
added += 1;
}
Err(e) => {
tracing::error!("failed to persist permission {} -> {}: {:?}", peer, peer_addr, e);
}
}
} else {
tracing::warn!(
"invalid XOR-PEER-ADDRESS in request from {}",
peer
);
}
}
if requires_auth {
match auth.authenticate(&msg, &peer).await {
AuthStatus::Granted { username } => {
tracing::debug!(
"TURN auth ok for {} as {} (0x{:04x})",
peer,
username,
msg.header.msg_type
);
}
AuthStatus::Challenge { nonce } => {
let resp = build_401_response(
&msg.header,
auth.realm(),
&nonce,
401,
"Unauthorized",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
AuthStatus::StaleNonce { nonce } => {
let resp = build_401_response(
&msg.header,
auth.realm(),
&nonce,
438,
"Stale Nonce",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
AuthStatus::Reject { code, reason } => {
let resp = build_error_response(&msg.header, code, reason);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
}
if added == 0 {
let resp = build_error_response(
&msg.header,
400,
"No valid XOR-PEER-ADDRESS",
);
let _ = udp.send_to(&resp, &peer).await;
} else {
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
continue;
} else if msg.header.msg_type == METHOD_CHANNEL_BIND {
let allocation = match allocs.get_allocation(&peer) {
Some(a) => a,
None => {
tracing::warn!(
"channel-bind without allocation from {}",
peer
);
let resp = build_error_response(
&msg.header,
437,
"Allocation Mismatch",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
let channel_attr = msg
.attributes
.iter()
.find(|a| a.typ == ATTR_CHANNEL_NUMBER);
let peer_attr = msg
.attributes
.iter()
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
let channel = match channel_attr.and_then(|attr| {
if attr.value.len() >= 4 {
Some(u16::from_be_bytes([attr.value[0], attr.value[1]]))
} else {
None
}
}) {
Some(c) => c,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing CHANNEL-NUMBER",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if channel < 0x4000 || channel > 0x7FFF {
let resp = build_error_response(
&msg.header,
400,
"Channel Out Of Range",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
let peer_addr = match peer_attr.and_then(|attr| {
decode_xor_peer_address(
&attr.value,
&msg.header.transaction_id,
)
}) {
Some(addr) => addr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing XOR-PEER-ADDRESS",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if !allocation.is_peer_allowed(&peer_addr) {
let resp = build_error_response(
&msg.header,
403,
"Peer Not Permitted",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
match allocs.add_channel_binding(peer, channel, peer_addr) {
Ok(()) => {
tracing::info!(
"bound channel 0x{:04x} for {} -> {}",
channel,
peer,
peer_addr
);
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
Err(e) => {
tracing::error!(
"failed to add channel binding {} -> {} (channel 0x{:04x}): {:?}",
peer, peer_addr, channel, e
);
let resp = build_error_response(
&msg.header,
500,
"Channel Binding Failed",
);
let _ = udp.send_to(&resp, &peer).await;
}
}
continue;
} else if msg.header.msg_type == METHOD_SEND {
let allocation = match allocs.get_allocation(&peer) {
Some(a) => a,
None => {
tracing::warn!("send without allocation from {}", peer);
let resp = build_error_response(
&msg.header,
437,
"Allocation Mismatch",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
let peer_attr = msg
.attributes
.iter()
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
let data_attr =
msg.attributes.iter().find(|a| a.typ == ATTR_DATA);
let peer_addr = match peer_attr.and_then(|attr| {
decode_xor_peer_address(
&attr.value,
&msg.header.transaction_id,
)
}) {
Some(addr) => addr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing XOR-PEER-ADDRESS",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
let data_attr = match data_attr {
Some(attr) => attr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing DATA Attribute",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if !allocation.is_peer_allowed(&peer_addr) {
let resp = build_error_response(
&msg.header,
403,
"Peer Not Permitted",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
match allocation.send_to_peer(peer_addr, &data_attr.value).await
{
Ok(sent) => {
tracing::info!(
"forwarded {} bytes from {} to peer {}",
sent,
peer,
peer_addr
);
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
Err(e) => {
tracing::error!(
"failed to send payload from {} to {}: {:?}",
peer,
peer_addr,
e
);
let resp = build_error_response(
&msg.header,
500,
"Peer Send Failed",
);
let _ = udp.send_to(&resp, &peer).await;
}
}
continue;
match msg.header.msg_type {
METHOD_ALLOCATE => {
use bytes::BytesMut;
match allocs.allocate_for(peer, udp.clone()).await {
Ok(relay_addr) => {
let mut out = BytesMut::new();
let success_type = msg.header.msg_type | CLASS_SUCCESS;
out.extend_from_slice(&success_type.to_be_bytes());
out.extend_from_slice(&0u16.to_be_bytes());
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
out.extend_from_slice(&msg.header.transaction_id);
let attr_val = encode_xor_relayed_address(
&relay_addr,
&msg.header.transaction_id,
);
out.extend_from_slice(&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes());
out.extend_from_slice(&((attr_val.len() as u16).to_be_bytes()));
out.extend_from_slice(&attr_val);
while (out.len() % 4) != 0 {
out.extend_from_slice(&[0]);
}
// Non-specific success path: echo a success response so the client continues handshake.
let resp = build_success_response(&msg.header);
let total_len = (out.len() - 20) as u16;
let len_bytes = total_len.to_be_bytes();
out[2] = len_bytes[0];
out[3] = len_bytes[1];
let vec_out = out.to_vec();
tracing::info!(
"sending allocate success -> {} bytes hex={} ",
vec_out.len(),
hex::encode(&vec_out)
);
let _ = udp.send_to(&vec_out, &peer).await;
}
Err(e) => {
tracing::error!("allocate failed: {:?}", e);
let resp =
build_error_response(&msg.header, 500, "Allocate Failed");
let _ = udp.send_to(&resp, &peer).await;
}
}
continue;
}
METHOD_CREATE_PERMISSION => {
if allocs.get_allocation(&peer).is_none() {
tracing::warn!("create-permission without allocation from {}", peer);
let resp =
build_error_response(&msg.header, 437, "Allocation Mismatch");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
let mut added = 0usize;
for attr in msg
.attributes
.iter()
.filter(|a| a.typ == ATTR_XOR_PEER_ADDRESS)
{
if let Some(peer_addr) =
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
{
match allocs.add_permission(peer, peer_addr) {
Ok(()) => {
tracing::info!(
"added permission for {} -> {}",
peer,
peer_addr
);
added += 1;
}
Err(e) => {
tracing::error!(
"failed to persist permission {} -> {}: {:?}",
peer,
peer_addr,
e
);
}
}
} else {
tracing::warn!("invalid XOR-PEER-ADDRESS in request from {}", peer);
}
}
if added == 0 {
let resp =
build_error_response(&msg.header, 400, "No valid XOR-PEER-ADDRESS");
let _ = udp.send_to(&resp, &peer).await;
} else {
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
continue;
}
METHOD_CHANNEL_BIND => {
let allocation = match allocs.get_allocation(&peer) {
Some(a) => a,
None => {
tracing::warn!("channel-bind without allocation from {}", peer);
let resp =
build_error_response(&msg.header, 437, "Allocation Mismatch");
let _ = udp.send_to(&resp, &peer).await;
continue;
} else {
tracing::info!("MI invalid for user {}", username);
}
} else {
tracing::info!("unknown user {}", username);
};
let channel_attr =
msg.attributes.iter().find(|a| a.typ == ATTR_CHANNEL_NUMBER);
let peer_attr = msg
.attributes
.iter()
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
let channel = match channel_attr.and_then(|attr| {
if attr.value.len() >= 4 {
Some(u16::from_be_bytes([attr.value[0], attr.value[1]]))
} else {
None
}
}) {
Some(c) => c,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing CHANNEL-NUMBER",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if channel < 0x4000 || channel > 0x7FFF {
let resp =
build_error_response(&msg.header, 400, "Channel Out Of Range");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
let peer_addr = match peer_attr.and_then(|attr| {
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
}) {
Some(addr) => addr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing XOR-PEER-ADDRESS",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if !allocation.is_peer_allowed(&peer_addr) {
let resp = build_error_response(&msg.header, 403, "Peer Not Permitted");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
match allocs.add_channel_binding(peer, channel, peer_addr) {
Ok(()) => {
tracing::info!(
"bound channel 0x{:04x} for {} -> {}",
channel,
peer,
peer_addr
);
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
Err(e) => {
tracing::error!(
"failed to add channel binding {} -> {} (channel 0x{:04x}): {:?}",
peer,
peer_addr,
channel,
e
);
let resp = build_error_response(
&msg.header,
500,
"Channel Binding Failed",
);
let _ = udp.send_to(&resp, &peer).await;
}
}
continue;
}
METHOD_SEND => {
let allocation = match allocs.get_allocation(&peer) {
Some(a) => a,
None => {
tracing::warn!("send without allocation from {}", peer);
let resp =
build_error_response(&msg.header, 437, "Allocation Mismatch");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
let peer_attr = msg
.attributes
.iter()
.find(|a| a.typ == ATTR_XOR_PEER_ADDRESS);
let data_attr = msg.attributes.iter().find(|a| a.typ == ATTR_DATA);
let peer_addr = match peer_attr.and_then(|attr| {
decode_xor_peer_address(&attr.value, &msg.header.transaction_id)
}) {
Some(addr) => addr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing XOR-PEER-ADDRESS",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
let data_attr = match data_attr {
Some(attr) => attr,
None => {
let resp = build_error_response(
&msg.header,
400,
"Missing DATA Attribute",
);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
};
if !allocation.is_peer_allowed(&peer_addr) {
let resp = build_error_response(&msg.header, 403, "Peer Not Permitted");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
match allocation.send_to_peer(peer_addr, &data_attr.value).await {
Ok(sent) => {
tracing::info!(
"forwarded {} bytes from {} to peer {}",
sent,
peer,
peer_addr
);
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
Err(e) => {
tracing::error!(
"failed to send payload from {} to {}: {:?}",
peer,
peer_addr,
e
);
let resp =
build_error_response(&msg.header, 500, "Peer Send Failed");
let _ = udp.send_to(&resp, &peer).await;
}
}
continue;
}
METHOD_REFRESH => {
// Refresh support is still MVP-level; acknowledge so clients extend allocations.
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
continue;
}
_ => {
let resp = build_error_response(&msg.header, 420, "Unknown TURN Method");
let _ = udp.send_to(&resp, &peer).await;
continue;
}
}
}
// Allow unauthenticated Allocate to fall back to challenge/early success for now (MVP compatibility).
if msg.header.msg_type == METHOD_ALLOCATE {
// If we reach here without MI, still attempt allocation but we will send a 401 earlier
let relay = allocs.allocate_for(peer, udp.clone()).await;
match relay {
Ok(relay_addr) => {
use bytes::BytesMut;
let mut out = BytesMut::new();
let success_type = msg.header.msg_type | CLASS_SUCCESS;
out.extend_from_slice(&success_type.to_be_bytes());
out.extend_from_slice(&0u16.to_be_bytes());
out.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
out.extend_from_slice(&msg.header.transaction_id);
let attr_val =
encode_xor_relayed_address(&relay_addr, &msg.header.transaction_id);
out.extend_from_slice(&ATTR_XOR_RELAYED_ADDRESS.to_be_bytes());
out.extend_from_slice(&((attr_val.len() as u16).to_be_bytes()));
out.extend_from_slice(&attr_val);
while (out.len() % 4) != 0 {
out.extend_from_slice(&[0]);
}
let total_len = (out.len() - 20) as u16;
let len_bytes = total_len.to_be_bytes();
out[2] = len_bytes[0];
out[3] = len_bytes[1];
let vec_out = out.to_vec();
tracing::info!(
"sending allocate success (no-mi) -> {} bytes hex={} ",
vec_out.len(),
hex::encode(&vec_out)
);
let _ = udp.send_to(&vec_out, &peer).await;
}
Err(e) => {
error!("allocate failed: {:?}", e);
}
}
continue;
}
// Everything else receives a 401 challenge so the client can retry with credentials.
let nonce = format!("nonce-{}", uuid::Uuid::new_v4());
let resp = build_401_response(&msg.header, "niom-turn.local", &nonce, 401);
if let Err(e) = udp.send_to(&resp, &peer).await {
error!("failed to send 401: {:?}", e);
match msg.header.msg_type {
METHOD_BINDING => {
let resp = build_success_response(&msg.header);
let _ = udp.send_to(&resp, &peer).await;
}
_ => {
let nonce = auth.mint_nonce(&peer);
let resp =
build_401_response(&msg.header, auth.realm(), &nonce, 401, "Unauthorized");
if let Err(e) = udp.send_to(&resp, &peer).await {
error!("failed to send 401: {:?}", e);
}
}
}
} else {
tracing::debug!("Non-STUN or parse error from {} len={}", peer, len);

View File

@ -66,8 +66,14 @@ pub fn parse_message(buf: &[u8]) -> Result<StunMessage, ParseError> {
})
}
/// Build a minimal 401 error response (REALM + NONCE). Returns the bytes to send.
pub fn build_401_response(req: &StunHeader, realm: &str, nonce: &str, _err_code: u16) -> Vec<u8> {
/// Build a challenge error response with REALM + NONCE and the provided error code.
pub fn build_401_response(
req: &StunHeader,
realm: &str,
nonce: &str,
err_code: u16,
reason: &str,
) -> Vec<u8> {
use bytes::BytesMut;
let mut buf = BytesMut::new();
// Error response type for TURN: reuse the request method with error class bits set
@ -77,6 +83,20 @@ pub fn build_401_response(req: &StunHeader, realm: &str, nonce: &str, _err_code:
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
buf.extend_from_slice(&req.transaction_id);
// ERROR-CODE attribute (RFC 5389 section 15.6)
let mut err_value = Vec::new();
let class = (err_code / 100) as u8;
let number = (err_code % 100) as u8;
err_value.extend_from_slice(&[0, 0, class, number]);
err_value.extend_from_slice(reason.as_bytes());
buf.extend_from_slice(&ATTR_ERROR_CODE.to_be_bytes());
buf.extend_from_slice(&(err_value.len() as u16).to_be_bytes());
buf.extend_from_slice(&err_value);
while (buf.len() % 4) != 0 {
buf.extend_from_slice(&[0]);
}
// REALM (RFC attr)
let realm_bytes = realm.as_bytes();
buf.extend_from_slice(&ATTR_REALM.to_be_bytes());
@ -147,7 +167,7 @@ pub fn find_message_integrity(msg: &StunMessage) -> Option<&StunAttribute> {
/// Validate MESSAGE-INTEGRITY using provided key (password). Returns true if valid.
/// Note: This is a simplified validator that assumes the MESSAGE-INTEGRITY attribute exists and
/// that the message bytes passed are the full STUN message (including attributes).
pub fn validate_message_integrity(msg: &StunMessage, key: &str) -> bool {
pub fn validate_message_integrity(msg: &StunMessage, key: &[u8]) -> bool {
if let Some(mi) = find_message_integrity(msg) {
// MESSAGE-INTEGRITY attribute value is 20 bytes (HMAC-SHA1)
if mi.value.len() != 20 {
@ -189,11 +209,11 @@ pub fn compute_fingerprint(msg: &[u8]) -> u32 {
}
/// Compute MESSAGE-INTEGRITY (HMAC-SHA1) over the message
pub fn compute_message_integrity(key: &str, msg: &[u8]) -> Vec<u8> {
pub fn compute_message_integrity(key: &[u8], msg: &[u8]) -> Vec<u8> {
use hmac::{Hmac, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(key.as_bytes()).expect("HMAC key");
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC key");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
@ -284,7 +304,7 @@ mod tests {
cookie: MAGIC_COOKIE_U32,
transaction_id: [2u8; 12],
};
let out = build_401_response(&req, "realm", "nonce", 401);
let out = build_401_response(&req, "realm", "nonce", 401, "Unauthorized");
// parse back should succeed
let parsed = parse_message(&out).expect("parse resp");
assert!(!parsed.attributes.is_empty());
@ -331,7 +351,7 @@ mod tests {
buf[3] = len_bytes[1];
// Compute HMAC over message up to MI attribute header (mi_attr_offset)
let hmac = compute_message_integrity(password, &buf[..mi_attr_offset]);
let hmac = compute_message_integrity(password.as_bytes(), &buf[..mi_attr_offset]);
// place first 20 bytes into mi value
for i in 0..20 {
buf[mi_val_pos + i] = hmac[i];
@ -339,12 +359,12 @@ mod tests {
// Parse and validate
let parsed = parse_message(&buf).expect("parsed");
assert!(validate_message_integrity(&parsed, password));
assert!(validate_message_integrity(&parsed, password.as_bytes()));
// tamper: change one byte -> invalid
let mut tampered = buf.to_vec();
tampered[10] ^= 0xFF;
let parsed2 = parse_message(&tampered).expect("parsed2");
assert!(!validate_message_integrity(&parsed2, password));
assert!(!validate_message_integrity(&parsed2, password.as_bytes()));
}
}

1126
src/tls.rs

File diff suppressed because it is too large Load Diff