Initial commit: current niom-turn state
This commit is contained in:
commit
850354781d
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug
|
||||
target
|
||||
.DS_Store
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Generated by cargo mutants
|
||||
# Contains mutation testing data
|
||||
**/mutants.out*/
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
1079
Cargo.lock
generated
Normal file
1079
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
Cargo.toml
Normal file
35
Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "niom-turn"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# async runtime and networking
|
||||
tokio = { version = "1.39", features = ["full"] }
|
||||
bytes = "1.4"
|
||||
|
||||
# crypto
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# config and logging
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||
|
||||
# TLS for turns (server)
|
||||
tokio-rustls = "0.23"
|
||||
rustls = "0.21"
|
||||
rcgen = "0.9"
|
||||
|
||||
# small STUN helper
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
thiserror = "1.0"
|
||||
crc32fast = "1.3"
|
||||
md5 = "0.7"
|
||||
|
||||
111
README.md
Normal file
111
README.md
Normal file
@ -0,0 +1,111 @@
|
||||
niom-turn
|
||||
=========
|
||||
|
||||
Minimal TURN server scaffold for the niom project (MVP).
|
||||
|
||||
Goals
|
||||
- Provide a TURN server with long-term authentication and TLS (turns) for WebRTC clients.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
How to build
|
||||
|
||||
```bash
|
||||
cd niom-turn
|
||||
cargo build
|
||||
```
|
||||
|
||||
How to test (quick local smoke)
|
||||
- Start the server in one terminal (it listens on UDP/3478):
|
||||
|
||||
```bash
|
||||
cd niom-turn
|
||||
cargo run
|
||||
```
|
||||
|
||||
- From another machine or container, use a STUN client or `sipsak`/custom script to send a minimal STUN Binding request and observe the 401 reply.
|
||||
|
||||
Next steps
|
||||
- Implement full STUN attribute parsing (MESSAGE-INTEGRITY, FINGERPRINT).
|
||||
- Implement long-term auth validation using MESSAGE-INTEGRITY.
|
||||
- Implement Allocate + relayed sockets and permission handling.
|
||||
- Add TLS listener (port 5349) using `tokio-rustls` and support `turns:`.
|
||||
|
||||
Security / Deployment
|
||||
- For production, run behind properly provisioned TLS certs (Let's Encrypt or mounted certs) and secure credential storage.
|
||||
- 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.
|
||||
|
||||
License: MIT
|
||||
|
||||
Smoke-Test (End-to-End)
|
||||
-----------------------
|
||||
Diese Anleitung beschreibt, wie du lokal den laufenden TURN/STUN-Server prüfst und welche Ergebnisse zu erwarten sind.
|
||||
|
||||
1) Server starten
|
||||
|
||||
Starte den Server im Projektverzeichnis; die Ausgabe wird normal in stdout geschrieben. In meinen Tests habe ich den Server im Hintergrund gestartet und die Logs in `/tmp/niom-turn-server.log` umgeleitet:
|
||||
|
||||
```bash
|
||||
cd niom-turn
|
||||
# Im Vordergrund (für Entwicklung)
|
||||
cargo run --bin niom-turn
|
||||
|
||||
# Oder im Hintergrund mit Log-Redirect
|
||||
cargo run --bin niom-turn &>/tmp/niom-turn-server.log &
|
||||
```
|
||||
|
||||
2) Smoke-Client ausführen
|
||||
|
||||
Das Repo enthält ein kleines Test-Binary `smoke_client`, das eine STUN Binding-Request mit `USERNAME` und `MESSAGE-INTEGRITY` an `127.0.0.1:3478` sendet.
|
||||
|
||||
```bash
|
||||
# Build (falls noch nicht gebaut)
|
||||
cargo build --bin smoke_client
|
||||
|
||||
# Ausführen
|
||||
./target/debug/smoke_client
|
||||
```
|
||||
|
||||
3) Erwartetes Ergebnis
|
||||
|
||||
Der Smoke-Client gibt die erhaltenen Bytes aus. Bei einer erfolgreichen MESSAGE-INTEGRITY-Prüfung sendet der Server eine STUN Success Response (Message Type 0x0101). Beispielsweise habe ich folgende Rückgabe gesehen:
|
||||
|
||||
```
|
||||
got 20 bytes from 127.0.0.1:3478
|
||||
[01, 01, 00, 00, 21, 12, a4, 42, 07, 07, 07, 07, 07, 07, 07, 07, 07, 07, 07, 07]
|
||||
```
|
||||
|
||||
Erklärung:
|
||||
- `01 01` → STUN Success Response (0x0101)
|
||||
- `21 12 a4 42` → Magic Cookie (0x2112A442)
|
||||
- die folgenden 12 Bytes sind die Transaction ID (in diesem Test `07` wiederholt)
|
||||
|
||||
Das bedeutet: Der Server hat die MESSAGE-INTEGRITY des Requests akzeptiert und eine 200-Antwort gesendet.
|
||||
|
||||
Wenn stattdessen eine 401-Antwort (Challenge) ausgegeben wird, sieht man im Hex typischerweise einen Fehler-Response-Typ und REALM/NONCE-Attribute im Payload; das zeigt, dass die Authentifizierung nicht erfolgt ist und der Client die Challenge verarbeiten muss.
|
||||
|
||||
Hinweis
|
||||
- Die derzeitige Auth-Implementierung ist minimal und für Tests gedacht. Für Produktion bitte die README-Abschnitte "Auth caveat" beachten: sichere Credentials, TLS, und ggf. ephemeral credentials verwenden.
|
||||
|
||||
BIN
docs/plan-by-gpt5.odt
Normal file
BIN
docs/plan-by-gpt5.odt
Normal file
Binary file not shown.
2859
docs/rfc5389-stun.txt
Normal file
2859
docs/rfc5389-stun.txt
Normal file
File diff suppressed because it is too large
Load Diff
3755
docs/rfc5766-turn.txt
Normal file
3755
docs/rfc5766-turn.txt
Normal file
File diff suppressed because it is too large
Load Diff
63
src/alloc.rs
Normal file
63
src/alloc.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Allocation {
|
||||
pub client: SocketAddr,
|
||||
pub relay_addr: SocketAddr,
|
||||
// keep the socket so it stays bound
|
||||
_socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AllocationManager {
|
||||
inner: Arc<Mutex<HashMap<SocketAddr, Allocation>>>,
|
||||
}
|
||||
|
||||
impl AllocationManager {
|
||||
pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())) } }
|
||||
|
||||
/// Create a relay UDP socket for the given client and spawn a relay loop that forwards
|
||||
/// any packets received on the relay socket back to the client via the provided server socket.
|
||||
pub async fn allocate_for(&self, client: SocketAddr, server_sock: Arc<UdpSocket>) -> anyhow::Result<SocketAddr> {
|
||||
// bind relay socket to OS-chosen port
|
||||
let relay = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
let relay_local = relay.local_addr()?;
|
||||
let relay_arc = Arc::new(relay);
|
||||
|
||||
// spawn relay loop
|
||||
let relay_clone = relay_arc.clone();
|
||||
let server_sock_clone = server_sock.clone();
|
||||
let client_clone = client;
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
loop {
|
||||
match relay_clone.recv_from(&mut buf).await {
|
||||
Ok((len, src)) => {
|
||||
info!("relay got {} bytes from {} for client {}", len, src, client_clone);
|
||||
// forward to client via server socket
|
||||
let _ = server_sock_clone.send_to(&buf[..len], client_clone).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("relay socket error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let alloc = Allocation { client, relay_addr: relay_local, _socket: relay_arc };
|
||||
tracing::info!("created allocation for {} -> {}", client, relay_local);
|
||||
let mut m = self.inner.lock().unwrap();
|
||||
m.insert(client, alloc);
|
||||
Ok(relay_local)
|
||||
}
|
||||
|
||||
pub fn get_allocation(&self, client: &SocketAddr) -> Option<Allocation> {
|
||||
let m = self.inner.lock().unwrap();
|
||||
m.get(client).cloned()
|
||||
}
|
||||
}
|
||||
46
src/auth.rs
Normal file
46
src/auth.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use crate::traits::CredentialStore;
|
||||
|
||||
/// Simple in-memory credential store for MVP
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InMemoryStore {
|
||||
// simple map; for production replace with DB-backed store
|
||||
inner: Arc<std::sync::Mutex<std::collections::HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
pub fn new() -> Self {
|
||||
Self { inner: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())) }
|
||||
}
|
||||
|
||||
pub fn insert(&self, user: impl Into<String>, password: impl Into<String>) {
|
||||
let mut m = self.inner.lock().unwrap();
|
||||
m.insert(user.into(), password.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialStore for InMemoryStore {
|
||||
async fn get_password(&self, username: &str) -> Option<String> {
|
||||
let m = self.inner.lock().unwrap();
|
||||
m.get(username).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: compute MESSAGE-INTEGRITY (HMAC-SHA1 as bytes)
|
||||
pub fn compute_hmac_sha1_bytes(key: &str, data: &[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");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Compute A1 MD5(username:realm:password) as bytes for long-term credential derivation
|
||||
pub fn compute_a1_md5(username: &str, realm: &str, password: &str) -> Vec<u8> {
|
||||
let s = format!("{}:{}:{}", username, realm, password);
|
||||
let digest = md5::compute(s.as_bytes());
|
||||
digest.0.to_vec()
|
||||
}
|
||||
131
src/bin/allocate_smoke.rs
Normal file
131
src/bin/allocate_smoke.rs
Normal file
@ -0,0 +1,131 @@
|
||||
use bytes::BytesMut;
|
||||
use niom_turn::constants::*;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::UdpSocket;
|
||||
use std::time::Duration;
|
||||
|
||||
fn decode_xor_relayed_address_local(value: &[u8], _trans: &[u8;12]) -> Option<std::net::SocketAddr> {
|
||||
if value.len() < 8 { return None; }
|
||||
if value[1] != FAMILY_IPV4 { return None; }
|
||||
let xport = u16::from_be_bytes([value[2], value[3]]);
|
||||
let port = xport ^ ((MAGIC_COOKIE_U32 >> 16) as u16);
|
||||
let cookie_bytes = MAGIC_COOKIE_U32.to_be_bytes();
|
||||
let mut ipb = [0u8;4];
|
||||
for i in 0..4 { ipb[i] = value[4 + i] ^ cookie_bytes[i]; }
|
||||
let ip = std::net::Ipv4Addr::from(ipb);
|
||||
Some(std::net::SocketAddr::new(std::net::IpAddr::V4(ip), port))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let server: SocketAddr = "127.0.0.1:3478".parse()?;
|
||||
let local = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
let username = "testuser";
|
||||
let password = "secretpassword";
|
||||
|
||||
// Build Allocate request (method METHOD_ALLOCATE)
|
||||
let mut buf = BytesMut::new();
|
||||
buf.extend_from_slice(&METHOD_ALLOCATE.to_be_bytes()); // Allocate Request
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // length placeholder
|
||||
buf.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
|
||||
let trans = [13u8; 12];
|
||||
buf.extend_from_slice(&trans);
|
||||
|
||||
// USERNAME
|
||||
let uname = username.as_bytes();
|
||||
buf.extend_from_slice(&ATTR_USERNAME.to_be_bytes());
|
||||
buf.extend_from_slice(&(uname.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(uname);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// MESSAGE-INTEGRITY placeholder
|
||||
let mi_attr_offset = buf.len();
|
||||
buf.extend_from_slice(&ATTR_MESSAGE_INTEGRITY.to_be_bytes());
|
||||
buf.extend_from_slice(&(20u16).to_be_bytes());
|
||||
let mi_val_pos = buf.len();
|
||||
buf.extend_from_slice(&[0u8;20]);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// fix length
|
||||
let total_len = (buf.len() - 20) as u16;
|
||||
let len_bytes = total_len.to_be_bytes();
|
||||
buf[2] = len_bytes[0];
|
||||
buf[3] = len_bytes[1];
|
||||
|
||||
// compute HMAC over bytes up to MI attribute header
|
||||
{
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
let mut mac = HmacSha1::new_from_slice(password.as_bytes()).expect("HMAC key");
|
||||
mac.update(&buf[..mi_attr_offset]);
|
||||
let res = mac.finalize().into_bytes();
|
||||
for i in 0..20 { buf[mi_val_pos + i] = res[i]; }
|
||||
}
|
||||
|
||||
// send Allocate
|
||||
local.send_to(&buf, server).await?;
|
||||
|
||||
// receive response
|
||||
let mut r = vec![0u8; 1500];
|
||||
let (len, _addr) = local.recv_from(&mut r).await?;
|
||||
println!("got {} bytes", len);
|
||||
let resp = &r[..len];
|
||||
// expect success (RESP_BINDING_SUCCESS) with XOR-RELAYED-ADDRESS attr
|
||||
if resp.len() < 20 {
|
||||
anyhow::bail!("response too short");
|
||||
}
|
||||
let typ = u16::from_be_bytes([resp[0], resp[1]]);
|
||||
println!("resp type 0x{:04x}", typ);
|
||||
if typ != RESP_BINDING_SUCCESS {
|
||||
anyhow::bail!("expected success response, got 0x{:04x}", typ);
|
||||
}
|
||||
// parse attributes
|
||||
let length = u16::from_be_bytes([resp[2], resp[3]]) as usize;
|
||||
let total = 20 + length;
|
||||
let mut offset = 20;
|
||||
let mut relay_addr_opt: Option<SocketAddr> = None;
|
||||
while offset + 4 <= total {
|
||||
let atype = u16::from_be_bytes([resp[offset], resp[offset+1]]);
|
||||
let alen = u16::from_be_bytes([resp[offset+2], resp[offset+3]]) as usize;
|
||||
offset += 4;
|
||||
if offset + alen > total { break; }
|
||||
println!("attr type=0x{:04x} len={}", atype, alen);
|
||||
println!("raw: {}", hex::encode(&resp[offset..offset+alen]));
|
||||
if atype == ATTR_XOR_RELAYED_ADDRESS {
|
||||
// XOR-RELAYED-ADDRESS: decode via local helper
|
||||
if let Some(sa) = decode_xor_relayed_address_local(&resp[offset..offset+alen], &trans) {
|
||||
relay_addr_opt = Some(sa);
|
||||
}
|
||||
}
|
||||
offset += alen;
|
||||
let pad = (4 - (alen % 4)) % 4;
|
||||
offset += pad;
|
||||
}
|
||||
|
||||
let relay_addr = match relay_addr_opt {
|
||||
Some(a) => a,
|
||||
None => anyhow::bail!("no relay address in response"),
|
||||
};
|
||||
|
||||
println!("got relayed addr: {}", relay_addr);
|
||||
|
||||
// send test payload to relay addr
|
||||
let payload = b"hello-relay";
|
||||
local.send_to(payload, relay_addr).await?;
|
||||
|
||||
// wait for forwarded packet (should arrive via server socket) using tokio timeout
|
||||
let mut buf2 = vec![0u8; 1500];
|
||||
match tokio::time::timeout(Duration::from_secs(2), local.recv_from(&mut buf2)).await {
|
||||
Ok(Ok((l, src))) => {
|
||||
println!("received {} bytes from {}", l, src);
|
||||
let got = &buf2[..l];
|
||||
println!("payload: {:?}", got);
|
||||
if got == payload { println!("relay test success"); Ok(()) } else { anyhow::bail!("payload mismatch") }
|
||||
}
|
||||
Ok(Err(e)) => anyhow::bail!("recv error: {:?}", e),
|
||||
Err(_) => anyhow::bail!("no forwarded packet received: timeout"),
|
||||
}
|
||||
}
|
||||
64
src/bin/smoke_client.rs
Normal file
64
src/bin/smoke_client.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use bytes::BytesMut;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::UdpSocket;
|
||||
use niom_turn::constants::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let server: SocketAddr = "127.0.0.1:3478".parse()?;
|
||||
let local = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
// Build a minimal STUN Binding Request with USERNAME and placeholder MESSAGE-INTEGRITY
|
||||
let username = "testuser";
|
||||
let password = "secretpassword"; // matches server's in-memory creds
|
||||
|
||||
let mut buf = BytesMut::new();
|
||||
buf.extend_from_slice(&METHOD_BINDING.to_be_bytes()); // Binding Request
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // length placeholder
|
||||
buf.extend_from_slice(&MAGIC_COOKIE_U32.to_be_bytes());
|
||||
let trans = [7u8; 12];
|
||||
buf.extend_from_slice(&trans);
|
||||
|
||||
// USERNAME
|
||||
let uname = username.as_bytes();
|
||||
buf.extend_from_slice(&ATTR_USERNAME.to_be_bytes());
|
||||
buf.extend_from_slice(&(uname.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(uname);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// MESSAGE-INTEGRITY placeholder
|
||||
let mi_attr_offset = buf.len();
|
||||
buf.extend_from_slice(&ATTR_MESSAGE_INTEGRITY.to_be_bytes());
|
||||
buf.extend_from_slice(&(20u16).to_be_bytes());
|
||||
let mi_val_pos = buf.len();
|
||||
buf.extend_from_slice(&[0u8;20]);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// fix length
|
||||
let total_len = (buf.len() - 20) as u16;
|
||||
let len_bytes = total_len.to_be_bytes();
|
||||
buf[2] = len_bytes[0];
|
||||
buf[3] = len_bytes[1];
|
||||
|
||||
// compute HMAC over bytes up to MI attribute header
|
||||
{
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
let mut mac = HmacSha1::new_from_slice(password.as_bytes()).expect("HMAC key");
|
||||
mac.update(&buf[..mi_attr_offset]);
|
||||
let res = mac.finalize().into_bytes();
|
||||
for i in 0..20 { buf[mi_val_pos + i] = res[i]; }
|
||||
}
|
||||
|
||||
// send
|
||||
local.send_to(&buf, server).await?;
|
||||
|
||||
let mut r = vec![0u8; 1500];
|
||||
let (len, addr) = local.recv_from(&mut r).await?;
|
||||
println!("got {} bytes from {}", len, addr);
|
||||
// dump hex
|
||||
println!("{:02x?}", &r[..len]);
|
||||
Ok(())
|
||||
}
|
||||
24
src/constants.rs
Normal file
24
src/constants.rs
Normal file
@ -0,0 +1,24 @@
|
||||
//! Central constants for STUN/TURN implementations (magic cookie, attribute types, methods)
|
||||
|
||||
pub const MAGIC_COOKIE_U32: u32 = 0x2112A442;
|
||||
pub const MAGIC_COOKIE_BYTES: [u8;4] = MAGIC_COOKIE_U32.to_be_bytes();
|
||||
|
||||
// STUN Methods/Message Types (only those used in this MVP)
|
||||
pub const METHOD_BINDING: u16 = 0x0001;
|
||||
pub const METHOD_ALLOCATE: u16 = 0x0003;
|
||||
|
||||
// Common response/error types
|
||||
pub const RESP_BINDING_SUCCESS: u16 = 0x0101;
|
||||
|
||||
// Common attribute types
|
||||
pub const ATTR_USERNAME: u16 = 0x0006;
|
||||
pub const ATTR_MESSAGE_INTEGRITY: u16 = 0x0008;
|
||||
pub const ATTR_REALM: u16 = 0x0014;
|
||||
pub const ATTR_NONCE: u16 = 0x0015;
|
||||
|
||||
// TURN attrs
|
||||
pub const ATTR_XOR_RELAYED_ADDRESS: u16 = 0x0016;
|
||||
|
||||
// Some helper values
|
||||
pub const FAMILY_IPV4: u8 = 0x01;
|
||||
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! Library root for niom-turn shared modules
|
||||
pub mod constants;
|
||||
pub mod stun;
|
||||
pub mod auth;
|
||||
pub mod traits;
|
||||
pub mod models;
|
||||
pub mod alloc;
|
||||
|
||||
pub use crate::auth::*;
|
||||
pub use crate::stun::*;
|
||||
pub use crate::alloc::*;
|
||||
160
src/main.rs
Normal file
160
src/main.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::{info, error};
|
||||
|
||||
mod stun;
|
||||
mod auth;
|
||||
mod traits;
|
||||
mod models;
|
||||
mod alloc;
|
||||
mod constants;
|
||||
use crate::constants::*;
|
||||
use crate::auth::InMemoryStore;
|
||||
use crate::stun::{parse_message, build_401_response};
|
||||
use crate::traits::CredentialStore;
|
||||
// use crate::models::stun::StunHeader; // currently unused
|
||||
use crate::alloc::AllocationManager;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
info!("niom-turn starting");
|
||||
|
||||
// config
|
||||
let bind_addr: SocketAddr = "0.0.0.0:3478".parse()?;
|
||||
|
||||
// Initialize credential store (MVP demo user)
|
||||
let creds = InMemoryStore::new();
|
||||
creds.insert("testuser", "secretpassword");
|
||||
|
||||
// UDP listener for TURN/STUN
|
||||
let udp = UdpSocket::bind(bind_addr).await?;
|
||||
let udp = Arc::new(udp);
|
||||
|
||||
// allocation manager
|
||||
let alloc_mgr = AllocationManager::new();
|
||||
|
||||
// spawn packet handling loop
|
||||
let udp_clone = udp.clone();
|
||||
let creds_clone = creds.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 {
|
||||
error!("udp loop error: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// keep running
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn udp_reader_loop(udp: Arc<UdpSocket>, creds: InMemoryStore, allocs: AllocationManager) -> anyhow::Result<()> {
|
||||
let mut buf = vec![0u8; 1500];
|
||||
loop {
|
||||
let (len, peer) = udp.recv_from(&mut buf).await?;
|
||||
tracing::debug!("got {} bytes from {}", len, peer);
|
||||
|
||||
// Minimal STUN/TURN detection: parse STUN messages and send 401 challenge
|
||||
if let Ok(msg) = parse_message(&buf[..len]) {
|
||||
tracing::info!("STUN/TURN message from {} type=0x{:04x} len={}", peer, msg.header.msg_type, len);
|
||||
// If MESSAGE-INTEGRITY present, attempt validation using credential store
|
||||
if let Some(_mi_attr) = crate::stun::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 = crate::stun::validate_message_integrity(&msg, &password);
|
||||
if valid {
|
||||
tracing::info!("MI valid for user {}", username);
|
||||
// If this is an Allocate request, perform allocation
|
||||
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();
|
||||
out.extend_from_slice(&RESP_BINDING_SUCCESS.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 = crate::stun::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),
|
||||
}
|
||||
}
|
||||
// default success response
|
||||
let resp = crate::stun::build_success_response(&msg.header);
|
||||
let _ = udp.send_to(&resp, &peer).await;
|
||||
continue;
|
||||
} else {
|
||||
tracing::info!("MI invalid for user {}", username);
|
||||
}
|
||||
} else {
|
||||
tracing::info!("unknown user {}", username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If it's an Allocate request (TURN method ALLOCATE) and MI valid, allocate a relay socket
|
||||
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();
|
||||
out.extend_from_slice(&RESP_BINDING_SUCCESS.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 = crate::stun::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;
|
||||
}
|
||||
|
||||
// default: send 401 challenge
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Non-STUN or parse error from {} len={}", peer, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// existing helper functions moved to stun.rs
|
||||
3
src/models/mod.rs
Normal file
3
src/models/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod stun;
|
||||
|
||||
pub use stun::{StunHeader, StunAttribute, StunMessage};
|
||||
22
src/models/stun.rs
Normal file
22
src/models/stun.rs
Normal file
@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StunHeader {
|
||||
pub msg_type: u16,
|
||||
pub length: u16,
|
||||
pub cookie: u32,
|
||||
pub transaction_id: [u8; 12],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StunAttribute {
|
||||
pub typ: u16,
|
||||
pub value: Vec<u8>,
|
||||
/// byte offset in the original message where the attribute header starts (type field)
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StunMessage {
|
||||
pub header: StunHeader,
|
||||
pub attributes: Vec<StunAttribute>,
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
261
src/stun.rs
Normal file
261
src/stun.rs
Normal file
@ -0,0 +1,261 @@
|
||||
use std::convert::TryInto;
|
||||
use crate::models::stun::{StunHeader, StunAttribute, StunMessage};
|
||||
use crate::constants::*;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ParseError {
|
||||
#[error("too short")] TooShort,
|
||||
#[error("invalid magic cookie")] InvalidCookie,
|
||||
#[error("attribute overflow")] AttrOverflow,
|
||||
}
|
||||
|
||||
pub fn parse_message(buf: &[u8]) -> Result<StunMessage, ParseError> {
|
||||
if buf.len() < 20 { return Err(ParseError::TooShort); }
|
||||
let msg_type = u16::from_be_bytes(buf[0..2].try_into().unwrap());
|
||||
let length = u16::from_be_bytes(buf[2..4].try_into().unwrap());
|
||||
let cookie = u32::from_be_bytes(buf[4..8].try_into().unwrap());
|
||||
if cookie != MAGIC_COOKIE_U32 { return Err(ParseError::InvalidCookie); }
|
||||
let mut trans = [0u8; 12];
|
||||
trans.copy_from_slice(&buf[8..20]);
|
||||
|
||||
let mut attrs = Vec::new();
|
||||
let mut offset = 20usize;
|
||||
let total_len = (length as usize) + 20;
|
||||
if buf.len() < total_len { return Err(ParseError::TooShort); }
|
||||
|
||||
while offset + 4 <= total_len {
|
||||
let typ = u16::from_be_bytes(buf[offset..offset+2].try_into().unwrap());
|
||||
let attr_len = u16::from_be_bytes(buf[offset+2..offset+4].try_into().unwrap()) as usize;
|
||||
let attr_header_offset = offset;
|
||||
offset += 4;
|
||||
if offset + attr_len > total_len { return Err(ParseError::AttrOverflow); }
|
||||
let value = buf[offset..offset+attr_len].to_vec();
|
||||
attrs.push(StunAttribute { typ, value, offset: attr_header_offset });
|
||||
offset += attr_len;
|
||||
// padding to 32-bit boundary
|
||||
let pad = (4 - (attr_len % 4)) % 4;
|
||||
offset += pad;
|
||||
}
|
||||
|
||||
Ok(StunMessage {
|
||||
header: StunHeader { msg_type, length, cookie, transaction_id: trans },
|
||||
attributes: attrs,
|
||||
raw: buf[..total_len].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
use bytes::BytesMut;
|
||||
let mut buf = BytesMut::new();
|
||||
// Error response type for TURN often uses same method with error bit set; here we reuse 0x0111 placeholder
|
||||
let msg_type: u16 = 0x0111;
|
||||
buf.extend_from_slice(&msg_type.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // length
|
||||
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
|
||||
buf.extend_from_slice(&req.transaction_id);
|
||||
|
||||
// REALM (0x0014)
|
||||
let realm_bytes = realm.as_bytes();
|
||||
buf.extend_from_slice(&0x0014u16.to_be_bytes());
|
||||
buf.extend_from_slice(&(realm_bytes.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(realm_bytes);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0]); }
|
||||
|
||||
// NONCE (0x0015)
|
||||
let nonce_bytes = nonce.as_bytes();
|
||||
buf.extend_from_slice(&0x0015u16.to_be_bytes());
|
||||
buf.extend_from_slice(&(nonce_bytes.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(nonce_bytes);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0]); }
|
||||
|
||||
// Update length
|
||||
let total_len = (buf.len() - 20) as u16;
|
||||
let len_bytes = total_len.to_be_bytes();
|
||||
buf[2] = len_bytes[0];
|
||||
buf[3] = len_bytes[1];
|
||||
|
||||
buf.to_vec()
|
||||
}
|
||||
|
||||
/// Find MESSAGE-INTEGRITY attribute (ATTR_MESSAGE_INTEGRITY) if present
|
||||
pub fn find_message_integrity(msg: &StunMessage) -> Option<&StunAttribute> {
|
||||
msg.attributes.iter().find(|a| a.typ == ATTR_MESSAGE_INTEGRITY)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if let Some(mi) = find_message_integrity(msg) {
|
||||
// MESSAGE-INTEGRITY attribute value is 20 bytes (HMAC-SHA1)
|
||||
if mi.value.len() != 20 { return false; }
|
||||
// Compute HMAC over the message up to (but excluding) MESSAGE-INTEGRITY attribute header and value
|
||||
let mi_attr_start = mi.offset; // offset points to attribute header
|
||||
let msg_slice = &msg.raw[..mi_attr_start];
|
||||
let computed = crate::stun::compute_message_integrity(key, msg_slice);
|
||||
// compare first 20 bytes
|
||||
return &computed[..20] == mi.value.as_slice();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Build a simple success (200) response echoing transaction id
|
||||
pub fn build_success_response(req: &StunHeader) -> Vec<u8> {
|
||||
use bytes::BytesMut;
|
||||
let mut buf = BytesMut::new();
|
||||
let msg_type: u16 = RESP_BINDING_SUCCESS; // Binding success response (example)
|
||||
buf.extend_from_slice(&msg_type.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes());
|
||||
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
|
||||
buf.extend_from_slice(&req.transaction_id);
|
||||
let total_len = (buf.len() - 20) as u16;
|
||||
let len_bytes = total_len.to_be_bytes();
|
||||
buf[2] = len_bytes[0];
|
||||
buf[3] = len_bytes[1];
|
||||
buf.to_vec()
|
||||
}
|
||||
|
||||
/// Compute STUN fingerprint (XOR-32 of CRC32)
|
||||
pub fn compute_fingerprint(msg: &[u8]) -> u32 {
|
||||
use crc32fast::Hasher;
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(msg);
|
||||
let crc = hasher.finalize();
|
||||
crc ^ 0x5354554e
|
||||
}
|
||||
|
||||
/// Compute MESSAGE-INTEGRITY (HMAC-SHA1) over the message
|
||||
pub fn compute_message_integrity(key: &str, 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");
|
||||
mac.update(msg);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// STUN/TURN attribute type for XOR-RELAYED-ADDRESS per RFC5766
|
||||
/// (use ATTR_XOR_RELAYED_ADDRESS from crate::constants)
|
||||
// no-op; refer to constants::ATTR_XOR_RELAYED_ADDRESS where needed
|
||||
|
||||
/// Encode an IPv4 SocketAddr into XOR-RELAYED-ADDRESS attribute value.
|
||||
/// Format (per RFC5389/RFC5766): 1 byte family, 2 byte xport, 4 byte xaddr for IPv4
|
||||
pub fn encode_xor_relayed_address(addr: &std::net::SocketAddr, _trans_id: &[u8;12]) -> Vec<u8> {
|
||||
use std::net::IpAddr;
|
||||
let mut out = Vec::new();
|
||||
match addr.ip() {
|
||||
IpAddr::V4(v4) => {
|
||||
out.push(0); // first 8 bits zero per spec
|
||||
out.push(0x01); // family: 0x01 for IPv4
|
||||
// xport = port ^ (magic_cookie >> 16)
|
||||
let port = addr.port();
|
||||
let xport = (port ^ ((MAGIC_COOKIE_U32 >> 16) as u16)) as u16;
|
||||
out.extend_from_slice(&xport.to_be_bytes());
|
||||
// xaddr = ipv4 ^ magic_cookie
|
||||
let octets = v4.octets();
|
||||
let cookie_bytes = MAGIC_COOKIE_BYTES;
|
||||
for i in 0..4 { out.push(octets[i] ^ cookie_bytes[i]); }
|
||||
}
|
||||
IpAddr::V6(_v6) => {
|
||||
// For now, we don't support IPv6 in this MVP implementation
|
||||
// Return an empty vec to indicate unsupported
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode XOR-RELAYED-ADDRESS attribute value into SocketAddr (IPv4 only)
|
||||
pub fn decode_xor_relayed_address(value: &[u8], _trans_id: &[u8;12]) -> Option<std::net::SocketAddr> {
|
||||
if value.len() < 8 { return None; }
|
||||
if value[1] != 0x01 { return None; } // not IPv4
|
||||
let xport = u16::from_be_bytes([value[2], value[3]]);
|
||||
let port = xport ^ ((MAGIC_COOKIE_U32 >> 16) as u16);
|
||||
let cookie_bytes = MAGIC_COOKIE_BYTES;
|
||||
let mut ipb = [0u8;4];
|
||||
for i in 0..4 { ipb[i] = value[4 + i] ^ cookie_bytes[i]; }
|
||||
let ip = std::net::Ipv4Addr::from(ipb);
|
||||
Some(std::net::SocketAddr::new(std::net::IpAddr::V4(ip), port))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_binding() {
|
||||
// Build a minimal STUN Binding request with empty attributes
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&METHOD_BINDING.to_be_bytes()); // Binding Request
|
||||
b.extend_from_slice(&0u16.to_be_bytes()); // length
|
||||
b.extend_from_slice(&MAGIC_COOKIE_BYTES);
|
||||
let trans = [1u8; 12];
|
||||
b.extend_from_slice(&trans);
|
||||
let msg = parse_message(&b).expect("parse");
|
||||
assert_eq!(msg.header.msg_type, METHOD_BINDING);
|
||||
assert_eq!(msg.header.transaction_id, trans);
|
||||
assert!(msg.attributes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_401_roundtrip() {
|
||||
let req = StunHeader { msg_type: METHOD_BINDING, length: 0, cookie: MAGIC_COOKIE_U32, transaction_id: [2u8;12] };
|
||||
let out = build_401_response(&req, "realm", "nonce", 401);
|
||||
// parse back should succeed
|
||||
let parsed = parse_message(&out).expect("parse resp");
|
||||
assert!(!parsed.attributes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_integrity_valid_and_invalid() {
|
||||
use bytes::BytesMut;
|
||||
|
||||
let username = "alice";
|
||||
let password = "secret"; // used directly as HMAC key in this MVP
|
||||
|
||||
// Build message: Binding Request + USERNAME attribute + MESSAGE-INTEGRITY placeholder
|
||||
let mut buf = BytesMut::new();
|
||||
buf.extend_from_slice(&METHOD_BINDING.to_be_bytes()); // Binding Request
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // length placeholder
|
||||
buf.extend_from_slice(&0x2112A442u32.to_be_bytes());
|
||||
let trans = [9u8; 12];
|
||||
buf.extend_from_slice(&trans);
|
||||
|
||||
// USERNAME (ATTR_USERNAME)
|
||||
let uname_bytes = username.as_bytes();
|
||||
buf.extend_from_slice(&ATTR_USERNAME.to_be_bytes());
|
||||
buf.extend_from_slice(&(uname_bytes.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(uname_bytes);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// MESSAGE-INTEGRITY placeholder (0x0008) length 20
|
||||
let mi_attr_offset = buf.len();
|
||||
buf.extend_from_slice(&ATTR_MESSAGE_INTEGRITY.to_be_bytes());
|
||||
buf.extend_from_slice(&(20u16).to_be_bytes());
|
||||
let mi_val_pos = buf.len();
|
||||
buf.extend_from_slice(&[0u8;20]);
|
||||
while (buf.len() % 4) != 0 { buf.extend_from_slice(&[0u8]); }
|
||||
|
||||
// Fix length
|
||||
let total_len = (buf.len() - 20) as u16;
|
||||
let len_bytes = total_len.to_be_bytes();
|
||||
buf[2] = len_bytes[0];
|
||||
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]);
|
||||
// place first 20 bytes into mi value
|
||||
for i in 0..20 { buf[mi_val_pos + i] = hmac[i]; }
|
||||
|
||||
// Parse and validate
|
||||
let parsed = parse_message(&buf).expect("parsed");
|
||||
assert!(validate_message_integrity(&parsed, password));
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
8
src/traits/credential_store.rs
Normal file
8
src/traits/credential_store.rs
Normal file
@ -0,0 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// CredentialStore trait - async abstraction for credential backends
|
||||
#[async_trait]
|
||||
pub trait CredentialStore: Send + Sync + 'static {
|
||||
/// Look up a password for a username. Returns None if user not found.
|
||||
async fn get_password(&self, username: &str) -> Option<String>;
|
||||
}
|
||||
3
src/traits/mod.rs
Normal file
3
src/traits/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod credential_store;
|
||||
|
||||
pub use credential_store::CredentialStore;
|
||||
Loading…
x
Reference in New Issue
Block a user