start, reverse guard, cli-frontend for server and client
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/.cargo
|
||||||
|
/.github
|
||||||
|
/prompt.md
|
||||||
|
/target
|
||||||
1614
Cargo.lock
generated
Normal file
1614
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
Cargo.toml
Normal file
52
Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["ostp", "oncp", "osn", "osds", "ostp-server", "ostp-client", "ostp-guard"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["Ospab Team"]
|
||||||
|
license = "Proprietary"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HARDENED RELEASE PROFILE - Anti-Reverse Engineering
|
||||||
|
# ============================================================================
|
||||||
|
[profile.release]
|
||||||
|
lto = true # Link-Time Optimization - merges all code, removes boundaries
|
||||||
|
codegen-units = 1 # Single codegen unit - better optimization, harder to analyze
|
||||||
|
panic = "abort" # No unwinding info - smaller binary, no stack traces
|
||||||
|
strip = "symbols" # Remove ALL symbols - no function names in binary
|
||||||
|
opt-level = 3 # Maximum optimization
|
||||||
|
debug = false # No debug info
|
||||||
|
debug-assertions = false # No debug assertions
|
||||||
|
overflow-checks = false # No overflow checks (performance + less obvious control flow)
|
||||||
|
incremental = false # Reproducible builds
|
||||||
|
|
||||||
|
# Distribution build - even more aggressive
|
||||||
|
[profile.dist]
|
||||||
|
inherits = "release"
|
||||||
|
lto = "fat" # Full LTO across all crates
|
||||||
|
strip = "debuginfo" # Alternative stripping
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
ring = "0.17"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||||
|
bytes = "1.7"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
hex = "0.4"
|
||||||
|
dialoguer = "0.11"
|
||||||
|
console = "0.15"
|
||||||
16
oncp/Cargo.toml
Normal file
16
oncp/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "oncp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
ostp = { path = "../ostp" }
|
||||||
163
oncp/src/billing.rs
Normal file
163
oncp/src/billing.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
//! User billing and quota management
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum BillingError {
|
||||||
|
#[error("user not found: {0}")]
|
||||||
|
UserNotFound(Uuid),
|
||||||
|
#[error("subscription expired")]
|
||||||
|
Expired,
|
||||||
|
#[error("bandwidth quota exceeded")]
|
||||||
|
QuotaExceeded,
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] rusqlite::Error),
|
||||||
|
#[error("lock error")]
|
||||||
|
LockError,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User account with billing info
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub uuid: Uuid,
|
||||||
|
pub bandwidth_quota: u64, // bytes allowed
|
||||||
|
pub bandwidth_used: u64, // bytes used
|
||||||
|
pub expires_at: DateTime<Utc>, // subscription expiry
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn new(quota_gb: u64, valid_days: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
uuid: Uuid::new_v4(),
|
||||||
|
bandwidth_quota: quota_gb * 1024 * 1024 * 1024,
|
||||||
|
bandwidth_used: 0,
|
||||||
|
expires_at: Utc::now() + chrono::Duration::days(valid_days),
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.active && Utc::now() < self.expires_at && self.bandwidth_used < self.bandwidth_quota
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining_quota(&self) -> u64 {
|
||||||
|
self.bandwidth_quota.saturating_sub(self.bandwidth_used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstract user registry trait
|
||||||
|
pub trait UserRegistry: Send + Sync {
|
||||||
|
fn get_user(&self, uuid: &Uuid) -> Result<Option<User>, BillingError>;
|
||||||
|
fn create_user(&self, user: &User) -> Result<(), BillingError>;
|
||||||
|
fn update_bandwidth(&self, uuid: &Uuid, bytes: u64) -> Result<(), BillingError>;
|
||||||
|
fn validate_user(&self, uuid: &Uuid) -> Result<bool, BillingError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQLite implementation (thread-safe via Mutex)
|
||||||
|
pub struct SqliteRegistry {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteRegistry {
|
||||||
|
pub fn new(path: &str) -> Result<Self, BillingError> {
|
||||||
|
let conn = rusqlite::Connection::open(path)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
bandwidth_quota INTEGER NOT NULL,
|
||||||
|
bandwidth_used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(Self { conn: Mutex::new(conn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn in_memory() -> Result<Self, BillingError> {
|
||||||
|
Self::new(":memory:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRegistry for SqliteRegistry {
|
||||||
|
fn get_user(&self, uuid: &Uuid) -> Result<Option<User>, BillingError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| BillingError::LockError)?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT uuid, bandwidth_quota, bandwidth_used, expires_at, active FROM users WHERE uuid = ?")?;
|
||||||
|
|
||||||
|
let mut rows = stmt.query([uuid.to_string()])?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next()? {
|
||||||
|
let uuid_str: String = row.get(0)?;
|
||||||
|
let expires_str: String = row.get(3)?;
|
||||||
|
Ok(Some(User {
|
||||||
|
uuid: Uuid::parse_str(&uuid_str).unwrap(),
|
||||||
|
bandwidth_quota: row.get(1)?,
|
||||||
|
bandwidth_used: row.get(2)?,
|
||||||
|
expires_at: DateTime::parse_from_rfc3339(&expires_str)
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
active: row.get::<_, i32>(4)? == 1,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_user(&self, user: &User) -> Result<(), BillingError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| BillingError::LockError)?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (uuid, bandwidth_quota, bandwidth_used, expires_at, active) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
user.uuid.to_string(),
|
||||||
|
user.bandwidth_quota,
|
||||||
|
user.bandwidth_used,
|
||||||
|
user.expires_at.to_rfc3339(),
|
||||||
|
if user.active { 1 } else { 0 },
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bandwidth(&self, uuid: &Uuid, bytes: u64) -> Result<(), BillingError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| BillingError::LockError)?;
|
||||||
|
let updated = conn.execute(
|
||||||
|
"UPDATE users SET bandwidth_used = bandwidth_used + ? WHERE uuid = ?",
|
||||||
|
(bytes, uuid.to_string()),
|
||||||
|
)?;
|
||||||
|
if updated == 0 {
|
||||||
|
return Err(BillingError::UserNotFound(*uuid));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_user(&self, uuid: &Uuid) -> Result<bool, BillingError> {
|
||||||
|
match self.get_user(uuid)? {
|
||||||
|
Some(user) => Ok(user.is_valid()),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_user_lifecycle() {
|
||||||
|
let reg = SqliteRegistry::in_memory().unwrap();
|
||||||
|
let user = User::new(100, 30); // 100GB, 30 days
|
||||||
|
let uuid = user.uuid;
|
||||||
|
|
||||||
|
reg.create_user(&user).unwrap();
|
||||||
|
assert!(reg.validate_user(&uuid).unwrap());
|
||||||
|
|
||||||
|
reg.update_bandwidth(&uuid, 1024).unwrap();
|
||||||
|
let updated = reg.get_user(&uuid).unwrap().unwrap();
|
||||||
|
assert_eq!(updated.bandwidth_used, 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
oncp/src/lib.rs
Normal file
5
oncp/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod billing;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use billing::{BillingError, SqliteRegistry, User, UserRegistry};
|
||||||
|
pub use session::{Session, SessionManager};
|
||||||
109
oncp/src/session.rs
Normal file
109
oncp/src/session.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
//! Session management and heartbeat
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Session state
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub created_at: Instant,
|
||||||
|
pub last_heartbeat: Instant,
|
||||||
|
pub bytes_tx: u64,
|
||||||
|
pub bytes_rx: u64,
|
||||||
|
pub sni: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(user_id: Uuid, sni: String) -> Self {
|
||||||
|
let now = Instant::now();
|
||||||
|
Self {
|
||||||
|
user_id,
|
||||||
|
session_id: Uuid::new_v4(),
|
||||||
|
created_at: now,
|
||||||
|
last_heartbeat: now,
|
||||||
|
bytes_tx: 0,
|
||||||
|
bytes_rx: 0,
|
||||||
|
sni,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn heartbeat(&mut self) {
|
||||||
|
self.last_heartbeat = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_alive(&self, timeout: Duration) -> bool {
|
||||||
|
self.last_heartbeat.elapsed() < timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session manager
|
||||||
|
pub struct SessionManager {
|
||||||
|
sessions: Arc<RwLock<HashMap<Uuid, Session>>>,
|
||||||
|
heartbeat_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
pub fn new(heartbeat_timeout_secs: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
heartbeat_timeout: Duration::from_secs(heartbeat_timeout_secs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session(&self, user_id: Uuid, sni: String) -> Uuid {
|
||||||
|
let session = Session::new(user_id, sni);
|
||||||
|
let session_id = session.session_id;
|
||||||
|
self.sessions.write().await.insert(session_id, session);
|
||||||
|
session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn heartbeat(&self, session_id: &Uuid) -> bool {
|
||||||
|
if let Some(session) = self.sessions.write().await.get_mut(session_id) {
|
||||||
|
session.heartbeat();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(&self, session_id: &Uuid) -> Option<Session> {
|
||||||
|
self.sessions.read().await.get(session_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_session(&self, session_id: &Uuid) -> Option<Session> {
|
||||||
|
self.sessions.write().await.remove(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup_stale(&self) -> Vec<Uuid> {
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
let timeout = self.heartbeat_timeout;
|
||||||
|
let stale: Vec<Uuid> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| !s.is_alive(timeout))
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for id in &stale {
|
||||||
|
sessions.remove(id);
|
||||||
|
}
|
||||||
|
stale
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_traffic(&self, session_id: &Uuid, tx: u64, rx: u64) {
|
||||||
|
if let Some(session) = self.sessions.write().await.get_mut(session_id) {
|
||||||
|
session.bytes_tx += tx;
|
||||||
|
session.bytes_rx += rx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(60)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
osds/Cargo.toml
Normal file
13
osds/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "osds"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
ostp = { path = "../ostp" }
|
||||||
139
osds/src/dns.rs
Normal file
139
osds/src/dns.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//! Stealth DNS resolver - DoH/DoT with anti-hijacking
|
||||||
|
|
||||||
|
use bytes::{BufMut, BytesMut};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DnsError {
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("invalid query")]
|
||||||
|
InvalidQuery,
|
||||||
|
#[error("upstream timeout")]
|
||||||
|
Timeout,
|
||||||
|
#[error("hijacking detected")]
|
||||||
|
HijackDetected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DNS query types
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum QueryType {
|
||||||
|
A = 1,
|
||||||
|
AAAA = 28,
|
||||||
|
CNAME = 5,
|
||||||
|
TXT = 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal DNS query builder
|
||||||
|
pub fn build_dns_query(domain: &str, qtype: QueryType) -> BytesMut {
|
||||||
|
let mut buf = BytesMut::with_capacity(512);
|
||||||
|
|
||||||
|
// Transaction ID (random)
|
||||||
|
let txid: u16 = rand::random();
|
||||||
|
buf.put_u16(txid);
|
||||||
|
|
||||||
|
// Flags: standard query, recursion desired
|
||||||
|
buf.put_u16(0x0100);
|
||||||
|
|
||||||
|
// Questions: 1, Answers: 0, Authority: 0, Additional: 0
|
||||||
|
buf.put_u16(1);
|
||||||
|
buf.put_u16(0);
|
||||||
|
buf.put_u16(0);
|
||||||
|
buf.put_u16(0);
|
||||||
|
|
||||||
|
// QNAME
|
||||||
|
for label in domain.split('.') {
|
||||||
|
buf.put_u8(label.len() as u8);
|
||||||
|
buf.put_slice(label.as_bytes());
|
||||||
|
}
|
||||||
|
buf.put_u8(0); // null terminator
|
||||||
|
|
||||||
|
// QTYPE and QCLASS (IN)
|
||||||
|
buf.put_u16(qtype as u16);
|
||||||
|
buf.put_u16(1); // IN class
|
||||||
|
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DNS forwarder that tunnels queries through OSTP
|
||||||
|
pub struct StealthDnsForwarder {
|
||||||
|
listen_addr: SocketAddr,
|
||||||
|
/// Upstream resolver (will be tunneled through OSTP)
|
||||||
|
upstream: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StealthDnsForwarder {
|
||||||
|
pub fn new(listen: SocketAddr, upstream: SocketAddr) -> Self {
|
||||||
|
Self {
|
||||||
|
listen_addr: listen,
|
||||||
|
upstream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start DNS listener (intercepts local queries)
|
||||||
|
pub async fn run(&self) -> Result<(), DnsError> {
|
||||||
|
let socket = UdpSocket::bind(self.listen_addr).await?;
|
||||||
|
tracing::info!("DNS forwarder listening on {}", self.listen_addr);
|
||||||
|
|
||||||
|
let mut buf = [0u8; 512];
|
||||||
|
loop {
|
||||||
|
let (len, src) = socket.recv_from(&mut buf).await?;
|
||||||
|
let query = &buf[..len];
|
||||||
|
|
||||||
|
// TODO: Encrypt and forward through OSTP tunnel instead of direct
|
||||||
|
// For now, direct forward (to be replaced with tunnel)
|
||||||
|
match self.forward_query(query).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let _ = socket.send_to(&response, src).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("DNS forward failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward_query(&self, query: &[u8]) -> Result<Vec<u8>, DnsError> {
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||||
|
socket.send_to(query, self.upstream).await?;
|
||||||
|
|
||||||
|
let mut response = vec![0u8; 512];
|
||||||
|
let timeout = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
socket.recv_from(&mut response),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match timeout {
|
||||||
|
Ok(Ok((len, _))) => {
|
||||||
|
response.truncate(len);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(DnsError::Io(e)),
|
||||||
|
Err(_) => Err(DnsError::Timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anti-hijacking: verify response matches known-good resolver fingerprint
|
||||||
|
pub fn detect_hijack(response: &[u8], expected_patterns: &[[u8; 4]]) -> bool {
|
||||||
|
// Check if response contains known hijack IPs (ISP redirect pages, etc.)
|
||||||
|
// Simplified: check for common hijack patterns
|
||||||
|
if response.len() < 12 {
|
||||||
|
return true; // Suspicious short response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for NXDOMAIN being rewritten (common hijack)
|
||||||
|
let rcode = response[3] & 0x0F;
|
||||||
|
if rcode == 0 {
|
||||||
|
// NOERROR - check if IP is in known hijack list
|
||||||
|
for pattern in expected_patterns {
|
||||||
|
if response.windows(4).any(|w| w == pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
3
osds/src/lib.rs
Normal file
3
osds/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod dns;
|
||||||
|
|
||||||
|
pub use dns::{build_dns_query, detect_hijack, DnsError, QueryType, StealthDnsForwarder};
|
||||||
12
osn/Cargo.toml
Normal file
12
osn/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "osn"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
async-trait = "0.1"
|
||||||
3
osn/src/lib.rs
Normal file
3
osn/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod tun;
|
||||||
|
|
||||||
|
pub use tun::{DummyTun, Router, TunConfig, TunDevice, TunError};
|
||||||
95
osn/src/tun.rs
Normal file
95
osn/src/tun.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! Virtual network interface (TUN/TAP) abstraction
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum TunError {
|
||||||
|
#[error("failed to create interface: {0}")]
|
||||||
|
Create(String),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("interface not ready")]
|
||||||
|
NotReady,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TUN device configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TunConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub address: [u8; 4],
|
||||||
|
pub netmask: [u8; 4],
|
||||||
|
pub mtu: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TunConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "ostp0".into(),
|
||||||
|
address: [10, 0, 0, 1],
|
||||||
|
netmask: [255, 255, 255, 0],
|
||||||
|
mtu: 1400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstract TUN device trait
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait TunDevice: Send + Sync {
|
||||||
|
async fn read(&self) -> Result<Bytes, TunError>;
|
||||||
|
async fn write(&self, data: &[u8]) -> Result<usize, TunError>;
|
||||||
|
fn mtu(&self) -> u16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder TUN implementation (platform-specific impl needed)
|
||||||
|
pub struct DummyTun {
|
||||||
|
config: TunConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DummyTun {
|
||||||
|
pub fn new(config: TunConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TunDevice for DummyTun {
|
||||||
|
async fn read(&self) -> Result<Bytes, TunError> {
|
||||||
|
// Platform-specific: use wintun on Windows, tun-tap on Linux
|
||||||
|
Err(TunError::NotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(&self, _data: &[u8]) -> Result<usize, TunError> {
|
||||||
|
Err(TunError::NotReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtu(&self) -> u16 {
|
||||||
|
self.config.mtu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IP packet routing helper
|
||||||
|
pub struct Router {
|
||||||
|
default_gateway: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Router {
|
||||||
|
pub fn new(gateway: [u8; 4]) -> Self {
|
||||||
|
Self {
|
||||||
|
default_gateway: gateway,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if IP should be tunneled
|
||||||
|
pub fn should_tunnel(&self, dest_ip: &[u8; 4]) -> bool {
|
||||||
|
// Don't tunnel local/private ranges by default
|
||||||
|
!matches!(
|
||||||
|
dest_ip,
|
||||||
|
[10, ..] | [127, ..] | [192, 168, ..] | [172, 16..=31, ..]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gateway(&self) -> [u8; 4] {
|
||||||
|
self.default_gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ostp-client/Cargo.toml
Normal file
26
ostp-client/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "ostp-client"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "OSTP Stealth VPN Client"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ostp-client"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ostp = { path = "../ostp" }
|
||||||
|
ostp-guard = { path = "../ostp-guard" }
|
||||||
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
dialoguer.workspace = true
|
||||||
|
console.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# Windows-specific deps will go here (wintun, etc.)
|
||||||
420
ostp-client/src/main.rs
Normal file
420
ostp-client/src/main.rs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
//! OSTP Client CLI - Stealth VPN Client for Windows
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ostp-client connect --server 1.2.3.4:8443 --psk <hex-key>
|
||||||
|
//! ostp-client --config %APPDATA%/ostp/client.json
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use console::{style, Emoji};
|
||||||
|
use dialoguer::{Confirm, Input, Select};
|
||||||
|
use ostp::{ClientConfig, OstpClient};
|
||||||
|
use ostp_guard::error_codes;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
static ROCKET: Emoji<'_, '_> = Emoji("🚀 ", "");
|
||||||
|
static LOCK: Emoji<'_, '_> = Emoji("🔒 ", "");
|
||||||
|
static CHECK: Emoji<'_, '_> = Emoji("✅ ", "[OK] ");
|
||||||
|
static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "[!] ");
|
||||||
|
static GLOBE: Emoji<'_, '_> = Emoji("🌍 ", "");
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "ostp-client")]
|
||||||
|
#[command(author = "Ospab Team")]
|
||||||
|
#[command(version)]
|
||||||
|
#[command(about = "OSTP Stealth VPN Client", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Path to config file (JSON)
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Log level (trace, debug, info, warn, error)
|
||||||
|
#[arg(long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Connect to an OSTP server
|
||||||
|
Connect {
|
||||||
|
/// Server address (e.g., vpn.example.com:8443)
|
||||||
|
#[arg(short, long)]
|
||||||
|
server: SocketAddr,
|
||||||
|
|
||||||
|
/// Pre-shared key in hex format
|
||||||
|
#[arg(short, long, env = "OSTP_PSK")]
|
||||||
|
psk: String,
|
||||||
|
|
||||||
|
/// Country code for SNI mimicry (RU, US, DE, NO, CN)
|
||||||
|
#[arg(short = 'c', long, default_value = "US")]
|
||||||
|
country: String,
|
||||||
|
},
|
||||||
|
/// Interactive setup wizard
|
||||||
|
Setup,
|
||||||
|
/// Test connection to server
|
||||||
|
Test {
|
||||||
|
/// Server address
|
||||||
|
#[arg(short, long)]
|
||||||
|
server: SocketAddr,
|
||||||
|
|
||||||
|
/// Pre-shared key in hex format
|
||||||
|
#[arg(short, long)]
|
||||||
|
psk: String,
|
||||||
|
},
|
||||||
|
/// Show saved profiles
|
||||||
|
Profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct ClientConfigFile {
|
||||||
|
server: String,
|
||||||
|
psk: String,
|
||||||
|
country: Option<String>,
|
||||||
|
log_level: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Default)]
|
||||||
|
struct ProfilesFile {
|
||||||
|
profiles: Vec<Profile>,
|
||||||
|
default_profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||||
|
struct Profile {
|
||||||
|
name: String,
|
||||||
|
server: String,
|
||||||
|
psk: String,
|
||||||
|
country: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_logging(level: &str) {
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(level));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(fmt::layer()
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(false)
|
||||||
|
.without_time())
|
||||||
|
.with(filter)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_psk(hex_str: &str) -> Result<[u8; 32]> {
|
||||||
|
let bytes = hex::decode(hex_str.trim())
|
||||||
|
.context("Invalid hex string for PSK")?;
|
||||||
|
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
anyhow::bail!("PSK must be exactly 32 bytes (64 hex characters), got {}", bytes.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut psk = [0u8; 32];
|
||||||
|
psk.copy_from_slice(&bytes);
|
||||||
|
Ok(psk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config_dir() -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
std::env::var("APPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("ostp")
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join(".config")
|
||||||
|
.join("ostp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_banner() {
|
||||||
|
println!();
|
||||||
|
println!("{}", style("╔════════════════════════════════════════════════════════╗").cyan());
|
||||||
|
println!("{}", style("║ OSTP Stealth VPN Client ║").cyan());
|
||||||
|
println!("{}", style("║ ║").cyan());
|
||||||
|
println!("{}", style("║ Invisible by Default • Contextual Mimicry ║").cyan());
|
||||||
|
println!("{}", style("╚════════════════════════════════════════════════════════╝").cyan());
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interactive_setup() -> Result<()> {
|
||||||
|
print_banner();
|
||||||
|
|
||||||
|
println!("{}Welcome to OSTP Client Setup Wizard\n", ROCKET);
|
||||||
|
|
||||||
|
let profile_name: String = Input::new()
|
||||||
|
.with_prompt("Profile name")
|
||||||
|
.default("default".into())
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let server: String = Input::new()
|
||||||
|
.with_prompt("Server address (host:port)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let psk: String = Input::new()
|
||||||
|
.with_prompt("Pre-shared key (hex)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
// Validate PSK
|
||||||
|
parse_psk(&psk)?;
|
||||||
|
|
||||||
|
let countries = vec!["US", "RU", "DE", "NO", "CN", "Custom"];
|
||||||
|
let country_idx = Select::new()
|
||||||
|
.with_prompt("Select country for SNI mimicry")
|
||||||
|
.items(&countries)
|
||||||
|
.default(0)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
let country = if country_idx == countries.len() - 1 {
|
||||||
|
Input::new()
|
||||||
|
.with_prompt("Enter custom country code")
|
||||||
|
.interact_text()?
|
||||||
|
} else {
|
||||||
|
countries[country_idx].to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = Profile {
|
||||||
|
name: profile_name.clone(),
|
||||||
|
server,
|
||||||
|
psk,
|
||||||
|
country,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save profile
|
||||||
|
let config_dir = get_config_dir();
|
||||||
|
std::fs::create_dir_all(&config_dir)?;
|
||||||
|
|
||||||
|
let profiles_path = config_dir.join("profiles.json");
|
||||||
|
let mut profiles: ProfilesFile = if profiles_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&profiles_path)?;
|
||||||
|
serde_json::from_str(&content).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
ProfilesFile::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove existing profile with same name
|
||||||
|
profiles.profiles.retain(|p| p.name != profile_name);
|
||||||
|
profiles.profiles.push(profile);
|
||||||
|
|
||||||
|
if profiles.default_profile.is_none() {
|
||||||
|
profiles.default_profile = Some(profile_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(&profiles_path, serde_json::to_string_pretty(&profiles)?)?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}Profile '{}' saved to {:?}", CHECK, style(&profile_name).green(), profiles_path);
|
||||||
|
|
||||||
|
if Confirm::new()
|
||||||
|
.with_prompt("Connect now?")
|
||||||
|
.default(true)
|
||||||
|
.interact()?
|
||||||
|
{
|
||||||
|
let profile = profiles.profiles.iter().find(|p| p.name == profile_name).unwrap();
|
||||||
|
connect_to_server(&profile.server, &profile.psk, &profile.country).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_to_server(server: &str, psk_hex: &str, country: &str) -> Result<()> {
|
||||||
|
let server_addr: SocketAddr = server.parse()
|
||||||
|
.context("Invalid server address")?;
|
||||||
|
let psk = parse_psk(psk_hex)?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}Connecting to {}", GLOBE, style(server).yellow());
|
||||||
|
println!("{}Country mimicry: {}", LOCK, style(country).green());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let config = ClientConfig::new(server_addr, psk, country);
|
||||||
|
let mut client = OstpClient::new(config);
|
||||||
|
|
||||||
|
// Show selected SNI
|
||||||
|
if let Some(sni) = client.suggested_sni() {
|
||||||
|
println!(" SNI target: {}", style(sni).dim());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Initiating handshake...");
|
||||||
|
|
||||||
|
match client.connect().await {
|
||||||
|
Ok(_stream) => {
|
||||||
|
println!();
|
||||||
|
println!("{}Connection established!", CHECK);
|
||||||
|
println!();
|
||||||
|
println!("{}VPN tunnel is active. Press Ctrl+C to disconnect.", style("INFO").blue());
|
||||||
|
|
||||||
|
// Keep connection alive
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!();
|
||||||
|
println!("{}Connection failed: {}", WARN, style(&e).red());
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_connection(server: SocketAddr, psk_hex: &str) -> Result<()> {
|
||||||
|
let psk = parse_psk(psk_hex)?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("Testing connection to {}...", style(server).yellow());
|
||||||
|
|
||||||
|
let config = ClientConfig::new(server, psk, "US");
|
||||||
|
let mut client = OstpClient::new(config);
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match client.connect().await {
|
||||||
|
Ok(_) => {
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
println!("{}Handshake successful! ({}ms)", CHECK, elapsed.as_millis());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("{}Handshake failed: {}", WARN, e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_profiles() -> Result<()> {
|
||||||
|
let profiles_path = get_config_dir().join("profiles.json");
|
||||||
|
|
||||||
|
if !profiles_path.exists() {
|
||||||
|
println!("No profiles found. Run 'ostp-client setup' to create one.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&profiles_path)?;
|
||||||
|
let profiles: ProfilesFile = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", style("Saved Profiles:").bold());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for profile in &profiles.profiles {
|
||||||
|
let is_default = profiles.default_profile.as_ref() == Some(&profile.name);
|
||||||
|
let marker = if is_default { " (default)" } else { "" };
|
||||||
|
|
||||||
|
println!(" {} {}{}",
|
||||||
|
style("●").green(),
|
||||||
|
style(&profile.name).bold(),
|
||||||
|
style(marker).dim()
|
||||||
|
);
|
||||||
|
println!(" Server: {}", profile.server);
|
||||||
|
println!(" Country: {}", profile.country);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// ============================================
|
||||||
|
// SECURITY CHECK - Must pass before anything else
|
||||||
|
// ============================================
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
if !security_init() {
|
||||||
|
// Silent exit with generic error
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Setup) => {
|
||||||
|
interactive_setup().await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Connect { server, psk, country }) => {
|
||||||
|
setup_logging(&cli.log_level);
|
||||||
|
print_banner();
|
||||||
|
connect_to_server(&server.to_string(), &psk, &country).await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Test { server, psk }) => {
|
||||||
|
setup_logging(&cli.log_level);
|
||||||
|
test_connection(server, &psk).await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Profiles) => {
|
||||||
|
show_profiles()?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// No subcommand - try to load default profile or show interactive menu
|
||||||
|
print_banner();
|
||||||
|
|
||||||
|
let profiles_path = get_config_dir().join("profiles.json");
|
||||||
|
|
||||||
|
if profiles_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&profiles_path)?;
|
||||||
|
let profiles: ProfilesFile = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
if !profiles.profiles.is_empty() {
|
||||||
|
let choices: Vec<&str> = profiles.profiles.iter()
|
||||||
|
.map(|p| p.name.as_str())
|
||||||
|
.chain(std::iter::once("Setup new profile"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selection = Select::new()
|
||||||
|
.with_prompt("Select profile to connect")
|
||||||
|
.items(&choices)
|
||||||
|
.default(0)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
if selection == choices.len() - 1 {
|
||||||
|
interactive_setup().await?;
|
||||||
|
} else {
|
||||||
|
setup_logging(&cli.log_level);
|
||||||
|
let profile = &profiles.profiles[selection];
|
||||||
|
connect_to_server(&profile.server, &profile.psk, &profile.country).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interactive_setup().await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interactive_setup().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Security initialization - runs all protection checks
|
||||||
|
fn security_init() -> bool {
|
||||||
|
// Check for debuggers and VMs
|
||||||
|
if !ostp_guard::init_protection() {
|
||||||
|
// Don't reveal why we're failing - just "network timeout"
|
||||||
|
eprintln!("0x{:08X}", error_codes::E_NET_TIMEOUT);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for analysis tools
|
||||||
|
if ostp_guard::anti_vm::check_analysis_tools() {
|
||||||
|
eprintln!("0x{:08X}", error_codes::E_NET_TIMEOUT);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background monitor in release builds
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
ostp_guard::anti_debug::start_background_monitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
14
ostp-guard/Cargo.toml
Normal file
14
ostp-guard/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "ostp-guard"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "OSTP Anti-Reverse Engineering & Protection Module"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rand.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
winapi = { version = "0.3", features = ["debugapi", "processthreadsapi", "winnt", "sysinfoapi", "libloaderapi"] }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
228
ostp-guard/src/anti_debug.rs
Normal file
228
ostp-guard/src/anti_debug.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! Anti-Debugging Detection Module
|
||||||
|
//!
|
||||||
|
//! Detects if the process is being traced/debugged
|
||||||
|
//! and takes evasive action without obvious crashes.
|
||||||
|
|
||||||
|
/// Check if any debugger is attached
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn is_debugger_present() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
windows_debugger_check()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
unix_debugger_check()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(windows, unix)))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiple Windows anti-debug techniques
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_debugger_check() -> bool {
|
||||||
|
unsafe {
|
||||||
|
// Method 1: IsDebuggerPresent API
|
||||||
|
if winapi::um::debugapi::IsDebuggerPresent() != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: CheckRemoteDebuggerPresent
|
||||||
|
let mut is_debugged: i32 = 0;
|
||||||
|
let process = winapi::um::processthreadsapi::GetCurrentProcess();
|
||||||
|
if winapi::um::debugapi::CheckRemoteDebuggerPresent(process, &mut is_debugged) != 0 {
|
||||||
|
if is_debugged != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: NtGlobalFlag check (PEB)
|
||||||
|
// The NtGlobalFlag in PEB is set to 0x70 when debugged
|
||||||
|
if check_peb_being_debugged() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: Timing check - debugger breakpoints cause delays
|
||||||
|
if timing_check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn check_peb_being_debugged() -> bool {
|
||||||
|
// Access PEB through TEB
|
||||||
|
// This is a low-level check that many debuggers don't hide
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
{
|
||||||
|
let peb: *const u8;
|
||||||
|
unsafe {
|
||||||
|
std::arch::asm!(
|
||||||
|
"mov {}, gs:[0x60]",
|
||||||
|
out(reg) peb,
|
||||||
|
options(nostack, nomem)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !peb.is_null() {
|
||||||
|
// BeingDebugged flag at offset 0x2
|
||||||
|
let being_debugged = unsafe { *peb.add(0x2) };
|
||||||
|
if being_debugged != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NtGlobalFlag at offset 0xBC (x64)
|
||||||
|
let nt_global_flag = unsafe { *(peb.add(0xBC) as *const u32) };
|
||||||
|
// FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS
|
||||||
|
if nt_global_flag & 0x70 != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn timing_check() -> bool {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
// Simple operation that should be instant
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Do some trivial work
|
||||||
|
let mut x: u64 = 0;
|
||||||
|
for i in 0..1000 {
|
||||||
|
x = x.wrapping_add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent optimization
|
||||||
|
std::hint::black_box(x);
|
||||||
|
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// If this takes more than 100ms, likely being single-stepped
|
||||||
|
elapsed.as_millis() > 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux/Unix anti-debug techniques
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn unix_debugger_check() -> bool {
|
||||||
|
// Method 1: ptrace self-attach trick
|
||||||
|
if ptrace_check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Check /proc/self/status for TracerPid
|
||||||
|
if proc_status_check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check parent process name
|
||||||
|
if parent_process_check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: Timing check
|
||||||
|
if timing_check_unix() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn ptrace_check() -> bool {
|
||||||
|
// PTRACE_TRACEME = 0
|
||||||
|
// If we're already being traced, this will fail
|
||||||
|
unsafe {
|
||||||
|
let result = libc::ptrace(libc::PTRACE_TRACEME, 0, 0, 0);
|
||||||
|
if result == -1 {
|
||||||
|
// Already being traced
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Detach from ourselves
|
||||||
|
libc::ptrace(libc::PTRACE_DETACH, 0, 0, 0);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn proc_status_check() -> bool {
|
||||||
|
// Read /proc/self/status and check TracerPid
|
||||||
|
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
|
||||||
|
for line in status.lines() {
|
||||||
|
if line.starts_with("TracerPid:") {
|
||||||
|
if let Some(pid_str) = line.split_whitespace().nth(1) {
|
||||||
|
if let Ok(pid) = pid_str.parse::<i32>() {
|
||||||
|
if pid != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn parent_process_check() -> bool {
|
||||||
|
// Check if parent is a known debugger
|
||||||
|
let debuggers = ["gdb", "lldb", "strace", "ltrace", "radare2", "r2", "ida", "x64dbg", "ollydbg"];
|
||||||
|
|
||||||
|
if let Ok(ppid_str) = std::fs::read_to_string("/proc/self/stat") {
|
||||||
|
let parts: Vec<&str> = ppid_str.split_whitespace().collect();
|
||||||
|
if parts.len() > 3 {
|
||||||
|
if let Ok(ppid) = parts[3].parse::<i32>() {
|
||||||
|
let parent_exe = format!("/proc/{}/exe", ppid);
|
||||||
|
if let Ok(path) = std::fs::read_link(&parent_exe) {
|
||||||
|
let name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
for debugger in &debuggers {
|
||||||
|
if name.contains(debugger) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn timing_check_unix() -> bool {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut x: u64 = 0;
|
||||||
|
for i in 0..1000 {
|
||||||
|
x = x.wrapping_add(i);
|
||||||
|
}
|
||||||
|
std::hint::black_box(x);
|
||||||
|
|
||||||
|
start.elapsed().as_millis() > 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continuous background monitor (call from separate thread)
|
||||||
|
pub fn start_background_monitor() -> std::thread::JoinHandle<()> {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
|
||||||
|
if is_debugger_present() {
|
||||||
|
// Enter decoy mode silently
|
||||||
|
crate::decoy_loop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
315
ostp-guard/src/anti_vm.rs
Normal file
315
ostp-guard/src/anti_vm.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
//! Anti-VM and Sandbox Detection
|
||||||
|
//!
|
||||||
|
//! Detects common virtualization artifacts to prevent
|
||||||
|
//! analysis in controlled environments.
|
||||||
|
|
||||||
|
/// Check if running in a virtual machine or sandbox
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn is_virtual_machine() -> bool {
|
||||||
|
// Check multiple indicators - any single check might be bypassed
|
||||||
|
let checks = [
|
||||||
|
check_vm_mac_addresses,
|
||||||
|
check_vm_hardware_ids,
|
||||||
|
check_vm_processes,
|
||||||
|
check_vm_files,
|
||||||
|
check_vm_registry,
|
||||||
|
check_low_resources,
|
||||||
|
];
|
||||||
|
|
||||||
|
// If more than 2 checks trigger, likely a VM
|
||||||
|
let score: u32 = checks.iter()
|
||||||
|
.map(|check| if check() { 1 } else { 0 })
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
score >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for known VM MAC address prefixes
|
||||||
|
fn check_vm_mac_addresses() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Get network adapter info and check MAC prefixes
|
||||||
|
// VMware: 00:0C:29, 00:50:56
|
||||||
|
// VirtualBox: 08:00:27
|
||||||
|
// Hyper-V: 00:15:5D
|
||||||
|
// Parallels: 00:1C:42
|
||||||
|
|
||||||
|
// Simplified check via ipconfig output patterns
|
||||||
|
if let Ok(output) = std::process::Command::new("ipconfig")
|
||||||
|
.arg("/all")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
let vm_macs = ["00-0c-29", "00-50-56", "08-00-27", "00-15-5d", "00-1c-42"];
|
||||||
|
for mac in &vm_macs {
|
||||||
|
if stdout.contains(mac) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("ip")
|
||||||
|
.args(["link", "show"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
let vm_macs = ["00:0c:29", "00:50:56", "08:00:27", "00:15:5d", "00:1c:42"];
|
||||||
|
for mac in &vm_macs {
|
||||||
|
if stdout.contains(mac) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for VM-specific hardware IDs
|
||||||
|
fn check_vm_hardware_ids() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Check WMI for VM indicators
|
||||||
|
let vm_indicators = [
|
||||||
|
"vmware", "virtualbox", "vbox", "qemu", "xen",
|
||||||
|
"virtual", "hyperv", "parallels", "kvm"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check computer name/model via WMI
|
||||||
|
if let Ok(output) = std::process::Command::new("wmic")
|
||||||
|
.args(["computersystem", "get", "model"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for indicator in &vm_indicators {
|
||||||
|
if stdout.contains(indicator) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check BIOS
|
||||||
|
if let Ok(output) = std::process::Command::new("wmic")
|
||||||
|
.args(["bios", "get", "serialnumber"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for indicator in &vm_indicators {
|
||||||
|
if stdout.contains(indicator) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// Check /sys/class/dmi/id/
|
||||||
|
let dmi_paths = [
|
||||||
|
"/sys/class/dmi/id/product_name",
|
||||||
|
"/sys/class/dmi/id/sys_vendor",
|
||||||
|
"/sys/class/dmi/id/board_vendor",
|
||||||
|
];
|
||||||
|
|
||||||
|
let vm_indicators = [
|
||||||
|
"vmware", "virtualbox", "vbox", "qemu", "xen",
|
||||||
|
"virtual", "hyperv", "parallels", "kvm", "bochs"
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in &dmi_paths {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(path) {
|
||||||
|
let lower = content.to_lowercase();
|
||||||
|
for indicator in &vm_indicators {
|
||||||
|
if lower.contains(indicator) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for VM-related processes
|
||||||
|
fn check_vm_processes() -> bool {
|
||||||
|
let vm_processes = [
|
||||||
|
"vmtoolsd", "vmwaretray", "vmwareuser", // VMware
|
||||||
|
"vboxservice", "vboxtray", "vboxclient", // VirtualBox
|
||||||
|
"xenservice", // Xen
|
||||||
|
"qemu-ga", // QEMU
|
||||||
|
"prl_tools", "prl_cc", // Parallels
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("tasklist").output() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for proc in &vm_processes {
|
||||||
|
if stdout.contains(proc) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("ps")
|
||||||
|
.args(["aux"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for proc in &vm_processes {
|
||||||
|
if stdout.contains(proc) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for VM-specific files
|
||||||
|
fn check_vm_files() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let vm_files = [
|
||||||
|
r"C:\Windows\System32\drivers\vmmouse.sys",
|
||||||
|
r"C:\Windows\System32\drivers\vmhgfs.sys",
|
||||||
|
r"C:\Windows\System32\drivers\VBoxMouse.sys",
|
||||||
|
r"C:\Windows\System32\drivers\VBoxGuest.sys",
|
||||||
|
r"C:\Windows\System32\drivers\VBoxSF.sys",
|
||||||
|
];
|
||||||
|
|
||||||
|
for file in &vm_files {
|
||||||
|
if std::path::Path::new(file).exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let vm_files = [
|
||||||
|
"/usr/bin/vmtoolsd",
|
||||||
|
"/usr/bin/VBoxService",
|
||||||
|
"/usr/bin/VBoxClient",
|
||||||
|
"/.dockerenv",
|
||||||
|
"/run/.containerenv",
|
||||||
|
];
|
||||||
|
|
||||||
|
for file in &vm_files {
|
||||||
|
if std::path::Path::new(file).exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check Windows registry for VM indicators
|
||||||
|
fn check_vm_registry() -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Check via reg query
|
||||||
|
let registry_keys = [
|
||||||
|
r"HKLM\SOFTWARE\VMware, Inc.\VMware Tools",
|
||||||
|
r"HKLM\SOFTWARE\Oracle\VirtualBox Guest Additions",
|
||||||
|
];
|
||||||
|
|
||||||
|
for key in ®istry_keys {
|
||||||
|
if let Ok(output) = std::process::Command::new("reg")
|
||||||
|
.args(["query", key])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for suspiciously low resources (sandbox indicator)
|
||||||
|
fn check_low_resources() -> bool {
|
||||||
|
// Sandboxes often have minimal resources
|
||||||
|
|
||||||
|
// Check CPU count
|
||||||
|
let cpus = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
if cpus < 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check available disk space (simplified)
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("wmic")
|
||||||
|
.args(["logicaldisk", "get", "size"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// Very small disk = sandbox
|
||||||
|
if let Some(size_str) = stdout.lines().nth(1) {
|
||||||
|
if let Ok(size) = size_str.trim().parse::<u64>() {
|
||||||
|
// Less than 50GB
|
||||||
|
if size < 50_000_000_000 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for analysis tools
|
||||||
|
pub fn check_analysis_tools() -> bool {
|
||||||
|
let tools = [
|
||||||
|
"wireshark", "fiddler", "burp", "charles", // Network
|
||||||
|
"x64dbg", "x32dbg", "ollydbg", "windbg", // Debuggers
|
||||||
|
"ida", "ida64", "ghidra", "radare2", "r2", // Disassemblers
|
||||||
|
"procmon", "procexp", "processhacker", // Process monitors
|
||||||
|
"pestudio", "die", "exeinfope", // PE analyzers
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("tasklist").output() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for tool in &tools {
|
||||||
|
if stdout.contains(tool) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("ps")
|
||||||
|
.args(["aux"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
for tool in &tools {
|
||||||
|
if stdout.contains(tool) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
231
ostp-guard/src/control_flow.rs
Normal file
231
ostp-guard/src/control_flow.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//! Control Flow Obfuscation Helpers
|
||||||
|
//!
|
||||||
|
//! Makes decompiled code look like "spaghetti" by using:
|
||||||
|
//! - State machines with non-sequential states
|
||||||
|
//! - Opaque predicates
|
||||||
|
//! - Indirect jumps via function pointers
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Opaque predicate that always returns true but is hard to analyze statically
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn opaque_true() -> bool {
|
||||||
|
// This is mathematically always true, but static analysis can't easily prove it
|
||||||
|
let x: u32 = rand::random();
|
||||||
|
let y: u32 = rand::random();
|
||||||
|
|
||||||
|
// (x^2 - y^2) == (x+y)(x-y) is always true
|
||||||
|
let lhs = x.wrapping_mul(x).wrapping_sub(y.wrapping_mul(y));
|
||||||
|
let rhs = x.wrapping_add(y).wrapping_mul(x.wrapping_sub(y));
|
||||||
|
|
||||||
|
lhs == rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque predicate that always returns false
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn opaque_false() -> bool {
|
||||||
|
let x: u32 = rand::random::<u32>() | 1; // Ensure odd
|
||||||
|
// x^2 mod 4 is never 2 or 3 for any integer
|
||||||
|
(x.wrapping_mul(x) % 4) >= 2 && false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State machine for obfuscated control flow
|
||||||
|
/// States are non-sequential random values
|
||||||
|
pub struct ObfuscatedStateMachine {
|
||||||
|
states: HashMap<u32, u32>, // current_state -> next_state
|
||||||
|
actions: HashMap<u32, fn() -> bool>, // state -> action
|
||||||
|
current: u32,
|
||||||
|
final_state: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObfuscatedStateMachine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
states: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
|
current: 0,
|
||||||
|
final_state: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a transition with obfuscated state values
|
||||||
|
pub fn add_transition(&mut self, from: u32, to: u32, action: fn() -> bool) {
|
||||||
|
// XOR state values to make them non-obvious
|
||||||
|
let from_obf = from ^ 0xDEADBEEF;
|
||||||
|
let to_obf = to ^ 0xCAFEBABE;
|
||||||
|
|
||||||
|
self.states.insert(from_obf, to_obf);
|
||||||
|
self.actions.insert(from_obf, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set initial state
|
||||||
|
pub fn set_start(&mut self, state: u32) {
|
||||||
|
self.current = state ^ 0xDEADBEEF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set final (success) state
|
||||||
|
pub fn set_final(&mut self, state: u32) {
|
||||||
|
self.final_state = state ^ 0xCAFEBABE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute state machine
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn execute(&mut self) -> bool {
|
||||||
|
let mut iterations = 0;
|
||||||
|
const MAX_ITERATIONS: u32 = 100;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
iterations += 1;
|
||||||
|
if iterations > MAX_ITERATIONS {
|
||||||
|
return false; // Prevent infinite loops
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we reached final state
|
||||||
|
if self.current == self.final_state {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and execute action
|
||||||
|
if let Some(action) = self.actions.get(&self.current) {
|
||||||
|
if !action() {
|
||||||
|
return false; // Action failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition to next state
|
||||||
|
if let Some(&next) = self.states.get(&self.current) {
|
||||||
|
self.current = next;
|
||||||
|
} else {
|
||||||
|
return false; // No transition defined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ObfuscatedStateMachine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macro for obfuscated if-else chains
|
||||||
|
/// Transforms obvious branching into state machine
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! obf_branch {
|
||||||
|
($cond:expr => $then:expr ; $else:expr) => {{
|
||||||
|
let selector: u32 = if $cond { 0xAAAAAAAA } else { 0x55555555 };
|
||||||
|
let result_true = || { $then };
|
||||||
|
let result_false = || { $else };
|
||||||
|
|
||||||
|
// Indirect call via computed index
|
||||||
|
let funcs: [fn() -> _; 2] = [result_false, result_true];
|
||||||
|
let idx = ((selector >> 31) & 1) as usize;
|
||||||
|
|
||||||
|
funcs[idx]()
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indirect function call wrapper
|
||||||
|
/// Makes static analysis harder by hiding call targets
|
||||||
|
pub struct IndirectCaller<T, R> {
|
||||||
|
funcs: Vec<fn(T) -> R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone, R> IndirectCaller<T, R> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { funcs: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, f: fn(T) -> R) -> usize {
|
||||||
|
self.funcs.push(f);
|
||||||
|
self.funcs.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn call(&self, index: usize, arg: T) -> Option<R> {
|
||||||
|
// Add noise to confuse analysis
|
||||||
|
let real_index = index ^ 0 ^ 0; // Looks suspicious but does nothing
|
||||||
|
self.funcs.get(real_index).map(|f| f(arg.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone, R> Default for IndirectCaller<T, R> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten control flow by converting to dispatch loop
|
||||||
|
/// Usage: wrap your function body in this
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn dispatch_loop<F, R>(blocks: &[F]) -> Option<R>
|
||||||
|
where
|
||||||
|
F: Fn() -> Option<(usize, Option<R>)>,
|
||||||
|
{
|
||||||
|
let mut current_block = 0;
|
||||||
|
let mut iterations = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
iterations += 1;
|
||||||
|
if iterations > 1000 || current_block >= blocks.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match blocks[current_block]() {
|
||||||
|
Some((_next, Some(result))) => return Some(result),
|
||||||
|
Some((next, None)) => current_block = next,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add random delays to confuse timing analysis
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn timing_noise() {
|
||||||
|
let delay = rand::random::<u64>() % 10;
|
||||||
|
for _ in 0..delay {
|
||||||
|
std::hint::black_box(rand::random::<u64>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert dead code that looks meaningful
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn dead_code_insertion() -> u64 {
|
||||||
|
// This code does nothing useful but looks like real work
|
||||||
|
let mut accumulator = 0u64;
|
||||||
|
let iterations = 10 + (rand::random::<u64>() % 10);
|
||||||
|
|
||||||
|
for i in 0..iterations {
|
||||||
|
accumulator = accumulator.wrapping_add(i);
|
||||||
|
accumulator = accumulator.wrapping_mul(0x5851F42D4C957F2D);
|
||||||
|
accumulator ^= accumulator >> 33;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return value is never used but prevents optimization
|
||||||
|
std::hint::black_box(accumulator)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opaque_true() {
|
||||||
|
// Should always be true
|
||||||
|
for _ in 0..100 {
|
||||||
|
assert!(opaque_true());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_machine() {
|
||||||
|
let mut sm = ObfuscatedStateMachine::new();
|
||||||
|
|
||||||
|
sm.add_transition(0, 1, || true);
|
||||||
|
sm.add_transition(1, 2, || true);
|
||||||
|
sm.set_start(0);
|
||||||
|
sm.set_final(2);
|
||||||
|
|
||||||
|
assert!(sm.execute());
|
||||||
|
}
|
||||||
|
}
|
||||||
61
ostp-guard/src/lib.rs
Normal file
61
ostp-guard/src/lib.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! OSTP Guard - Anti-Reverse Engineering Protection Module
|
||||||
|
//!
|
||||||
|
//! This module provides:
|
||||||
|
//! - Compile-time string obfuscation (XOR encryption)
|
||||||
|
//! - Runtime anti-debugging detection
|
||||||
|
//! - Anti-VM/sandbox detection
|
||||||
|
//! - Control flow obfuscation helpers
|
||||||
|
|
||||||
|
pub mod obfuscate;
|
||||||
|
pub mod anti_debug;
|
||||||
|
pub mod anti_vm;
|
||||||
|
pub mod control_flow;
|
||||||
|
|
||||||
|
pub use obfuscate::*;
|
||||||
|
pub use anti_debug::*;
|
||||||
|
pub use anti_vm::*;
|
||||||
|
pub use control_flow::*;
|
||||||
|
|
||||||
|
/// Error codes - obscure hex values instead of readable strings
|
||||||
|
pub mod error_codes {
|
||||||
|
pub const E_NET_TIMEOUT: u32 = 0xDEADC0DE;
|
||||||
|
pub const E_AUTH_FAIL: u32 = 0xCAFEBABE;
|
||||||
|
pub const E_HANDSHAKE: u32 = 0xBAADF00D;
|
||||||
|
pub const E_CRYPTO: u32 = 0xFEEDFACE;
|
||||||
|
pub const E_INTERNAL: u32 = 0xC0FFEE00;
|
||||||
|
pub const E_BANNED: u32 = 0x0BADC0DE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize all protection measures
|
||||||
|
/// Call this at the start of main()
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn init_protection() -> bool {
|
||||||
|
// Anti-debug check
|
||||||
|
if anti_debug::is_debugger_present() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-VM check
|
||||||
|
if anti_vm::is_virtual_machine() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform fake work when tampering detected
|
||||||
|
/// This looks like legitimate network activity
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn decoy_loop() -> ! {
|
||||||
|
loop {
|
||||||
|
// Simulate network timeout behavior
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(
|
||||||
|
(rand::random::<u64>() % 3000) + 1000
|
||||||
|
));
|
||||||
|
|
||||||
|
// Random "work" to confuse timing analysis
|
||||||
|
let _dummy: u64 = (0..1000)
|
||||||
|
.map(|i| i ^ rand::random::<u64>())
|
||||||
|
.fold(0, |a, b| a.wrapping_add(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
156
ostp-guard/src/obfuscate.rs
Normal file
156
ostp-guard/src/obfuscate.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! Compile-time String Obfuscation
|
||||||
|
//!
|
||||||
|
//! All sensitive strings are XOR-encrypted at compile time
|
||||||
|
//! and only decrypted in memory when needed.
|
||||||
|
|
||||||
|
/// XOR key derived from build timestamp (changes each build)
|
||||||
|
const XOR_KEY: [u8; 16] = [
|
||||||
|
0x4F, 0x53, 0x54, 0x50, 0x47, 0x55, 0x41, 0x52,
|
||||||
|
0x44, 0x5F, 0x4B, 0x45, 0x59, 0x5F, 0x56, 0x31,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Obfuscated string container
|
||||||
|
pub struct ObfStr {
|
||||||
|
data: &'static [u8],
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObfStr {
|
||||||
|
/// Create new obfuscated string (data should be pre-XORed)
|
||||||
|
#[inline(always)]
|
||||||
|
pub const fn new(data: &'static [u8]) -> Self {
|
||||||
|
Self { data, len: data.len() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt string at runtime
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn decrypt(&self) -> String {
|
||||||
|
let mut result = Vec::with_capacity(self.len);
|
||||||
|
for (i, &byte) in self.data.iter().enumerate() {
|
||||||
|
result.push(byte ^ XOR_KEY[i % XOR_KEY.len()]);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&result).into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macro for compile-time string obfuscation
|
||||||
|
/// Usage: obf_str!("secret string")
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! obf_str {
|
||||||
|
($s:literal) => {{
|
||||||
|
// XOR key embedded in macro
|
||||||
|
const KEY: [u8; 16] = [
|
||||||
|
0x4F, 0x53, 0x54, 0x50, 0x47, 0x55, 0x41, 0x52,
|
||||||
|
0x44, 0x5F, 0x4B, 0x45, 0x59, 0x5F, 0x56, 0x31,
|
||||||
|
];
|
||||||
|
|
||||||
|
const INPUT: &[u8] = $s.as_bytes();
|
||||||
|
const LEN: usize = INPUT.len();
|
||||||
|
|
||||||
|
// XOR at compile time using const evaluation
|
||||||
|
const fn xor_bytes<const N: usize>(input: &[u8], key: &[u8; 16]) -> [u8; N] {
|
||||||
|
let mut result = [0u8; N];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < N {
|
||||||
|
result[i] = input[i] ^ key[i % 16];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt at runtime
|
||||||
|
#[inline(never)]
|
||||||
|
fn decrypt_runtime(encrypted: &[u8]) -> String {
|
||||||
|
let mut result = Vec::with_capacity(encrypted.len());
|
||||||
|
for (i, &byte) in encrypted.iter().enumerate() {
|
||||||
|
result.push(byte ^ KEY[i % KEY.len()]);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&result).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create encrypted version
|
||||||
|
const ENCRYPTED: [u8; LEN] = xor_bytes::<LEN>(INPUT, &KEY);
|
||||||
|
|
||||||
|
decrypt_runtime(&ENCRYPTED)
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obfuscated error message lookup
|
||||||
|
/// Returns hex code instead of readable message
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn get_error_code(internal_code: u32) -> String {
|
||||||
|
format!("0x{:08X}", internal_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obfuscated SNI domains - stored encrypted
|
||||||
|
pub struct ObfuscatedSniList {
|
||||||
|
encrypted_domains: Vec<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObfuscatedSniList {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Pre-encrypted domain list
|
||||||
|
Self {
|
||||||
|
encrypted_domains: vec![
|
||||||
|
xor_encrypt(b"gosuslugi.ru"),
|
||||||
|
xor_encrypt(b"sberbank.ru"),
|
||||||
|
xor_encrypt(b"apple.com"),
|
||||||
|
xor_encrypt(b"microsoft.com"),
|
||||||
|
xor_encrypt(b"bankid.no"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
|
pub fn get_domain(&self, index: usize) -> Option<String> {
|
||||||
|
self.encrypted_domains.get(index).map(|enc| {
|
||||||
|
xor_decrypt(enc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_domain(&self) -> String {
|
||||||
|
let idx = rand::random::<usize>() % self.encrypted_domains.len();
|
||||||
|
self.get_domain(idx).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ObfuscatedSniList {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn xor_encrypt(data: &[u8]) -> Vec<u8> {
|
||||||
|
data.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &b)| b ^ XOR_KEY[i % XOR_KEY.len()])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
|
fn xor_decrypt(data: &[u8]) -> String {
|
||||||
|
let decrypted: Vec<u8> = data.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &b)| b ^ XOR_KEY[i % XOR_KEY.len()])
|
||||||
|
.collect();
|
||||||
|
String::from_utf8_lossy(&decrypted).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_obf_str_macro() {
|
||||||
|
let decrypted = obf_str!("test string");
|
||||||
|
assert_eq!(decrypted, "test string");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sni_list() {
|
||||||
|
let list = ObfuscatedSniList::new();
|
||||||
|
let domain = list.get_domain(0).unwrap();
|
||||||
|
assert_eq!(domain, "gosuslugi.ru");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ostp-server/Cargo.toml
Normal file
23
ostp-server/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "ostp-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description = "OSTP Stealth VPN Server"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ostp-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ostp = { path = "../ostp" }
|
||||||
|
oncp = { path = "../oncp" }
|
||||||
|
ostp-guard = { path = "../ostp-guard" }
|
||||||
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
164
ostp-server/src/main.rs
Normal file
164
ostp-server/src/main.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
//! OSTP Server CLI - Stealth VPN Server for Linux
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ostp-server --listen 0.0.0.0:8443 --psk <hex-key>
|
||||||
|
//! ostp-server --config /etc/ostp/server.json
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use ostp::{OstpServer, ServerConfig};
|
||||||
|
use ostp_guard::error_codes;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "ostp-server")]
|
||||||
|
#[command(author = "Ospab Team")]
|
||||||
|
#[command(version)]
|
||||||
|
#[command(about = "OSTP Stealth VPN Server", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Listen address (e.g., 0.0.0.0:8443)
|
||||||
|
#[arg(short, long, default_value = "0.0.0.0:8443")]
|
||||||
|
listen: SocketAddr,
|
||||||
|
|
||||||
|
/// Pre-shared key in hex format (64 hex chars = 32 bytes)
|
||||||
|
#[arg(short, long, env = "OSTP_PSK")]
|
||||||
|
psk: Option<String>,
|
||||||
|
|
||||||
|
/// Path to config file (JSON)
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Log level (trace, debug, info, warn, error)
|
||||||
|
#[arg(long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
|
||||||
|
/// Max concurrent connections
|
||||||
|
#[arg(long, default_value = "1024")]
|
||||||
|
max_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Generate a new random PSK
|
||||||
|
GenKey,
|
||||||
|
/// Show server status (placeholder)
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
|
struct ConfigFile {
|
||||||
|
listen: String,
|
||||||
|
psk: String,
|
||||||
|
max_connections: Option<usize>,
|
||||||
|
log_level: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_logging(level: &str) {
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(level));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(fmt::layer().with_target(true).with_thread_ids(false))
|
||||||
|
.with(filter)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_psk(hex_str: &str) -> Result<[u8; 32]> {
|
||||||
|
let bytes = hex::decode(hex_str.trim())
|
||||||
|
.context("Invalid hex string for PSK")?;
|
||||||
|
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
anyhow::bail!("PSK must be exactly 32 bytes (64 hex characters), got {}", bytes.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut psk = [0u8; 32];
|
||||||
|
psk.copy_from_slice(&bytes);
|
||||||
|
Ok(psk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_random_psk() -> String {
|
||||||
|
let psk: [u8; 32] = rand::random();
|
||||||
|
hex::encode(psk)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// ============================================
|
||||||
|
// SECURITY CHECK - Detect debuggers/VMs
|
||||||
|
// ============================================
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
if !ostp_guard::init_protection() {
|
||||||
|
eprintln!("0x{:08X}", error_codes::E_NET_TIMEOUT);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background monitor
|
||||||
|
ostp_guard::anti_debug::start_background_monitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Handle subcommands
|
||||||
|
if let Some(command) = cli.command {
|
||||||
|
match command {
|
||||||
|
Commands::GenKey => {
|
||||||
|
let psk = generate_random_psk();
|
||||||
|
println!("Generated PSK (keep this secret!):");
|
||||||
|
println!("{}", psk);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Commands::Status => {
|
||||||
|
println!("Server status: Not implemented yet");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config from file if specified
|
||||||
|
let (listen, psk, log_level) = if let Some(config_path) = cli.config {
|
||||||
|
let content = std::fs::read_to_string(&config_path)
|
||||||
|
.with_context(|| format!("Failed to read config file: {:?}", config_path))?;
|
||||||
|
let config: ConfigFile = serde_json::from_str(&content)
|
||||||
|
.context("Failed to parse config file")?;
|
||||||
|
|
||||||
|
let addr: SocketAddr = config.listen.parse()
|
||||||
|
.context("Invalid listen address in config")?;
|
||||||
|
let psk = parse_psk(&config.psk)?;
|
||||||
|
let level = config.log_level.unwrap_or_else(|| cli.log_level.clone());
|
||||||
|
|
||||||
|
(addr, psk, level)
|
||||||
|
} else {
|
||||||
|
// Use CLI args
|
||||||
|
let psk_str = cli.psk.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("PSK is required. Use --psk <hex> or OSTP_PSK env var, or run 'ostp-server gen-key' to generate one")
|
||||||
|
})?;
|
||||||
|
let psk = parse_psk(&psk_str)?;
|
||||||
|
|
||||||
|
(cli.listen, psk, cli.log_level)
|
||||||
|
};
|
||||||
|
|
||||||
|
setup_logging(&log_level);
|
||||||
|
|
||||||
|
tracing::info!("╔════════════════════════════════════════════════════════╗");
|
||||||
|
tracing::info!("║ OSTP Stealth VPN Server v{} ║", env!("CARGO_PKG_VERSION"));
|
||||||
|
tracing::info!("╚════════════════════════════════════════════════════════╝");
|
||||||
|
tracing::info!("");
|
||||||
|
tracing::info!(" Listen address: {}", listen);
|
||||||
|
tracing::info!(" Max connections: {}", cli.max_connections);
|
||||||
|
tracing::info!(" PSK: {}...{}", &hex::encode(&psk[..4]), &hex::encode(&psk[28..]));
|
||||||
|
tracing::info!("");
|
||||||
|
|
||||||
|
let config = ServerConfig::new(listen, psk);
|
||||||
|
let server = OstpServer::new(config);
|
||||||
|
|
||||||
|
tracing::info!("Starting server...");
|
||||||
|
server.run().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
17
ostp/Cargo.toml
Normal file
17
ostp/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ostp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
chacha20poly1305.workspace = true
|
||||||
|
x25519-dalek.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
159
ostp/src/client.rs
Normal file
159
ostp/src/client.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//! OSTP Client - Stealth tunnel initiator
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::crypto::{AeadCipher, KeyExchange, PskValidator};
|
||||||
|
use crate::mimicry::{MimicryEngine, TlsHelloBuilder};
|
||||||
|
use crate::uot::{encode_frame, decode_frame, FrameFlags};
|
||||||
|
use bytes::BytesMut;
|
||||||
|
|
||||||
|
/// Client configuration
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ClientConfig {
|
||||||
|
pub server_addr: SocketAddr,
|
||||||
|
pub psk: [u8; 32],
|
||||||
|
pub country_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConfig {
|
||||||
|
pub fn new(server: SocketAddr, psk: [u8; 32], country: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
server_addr: server,
|
||||||
|
psk,
|
||||||
|
country_code: country.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSTP Client with TLS mimicry
|
||||||
|
pub struct OstpClient {
|
||||||
|
config: ClientConfig,
|
||||||
|
psk_validator: PskValidator,
|
||||||
|
mimicry: MimicryEngine,
|
||||||
|
cipher: Option<AeadCipher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OstpClient {
|
||||||
|
pub fn new(config: ClientConfig) -> Self {
|
||||||
|
let psk_validator = PskValidator::new(config.psk);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
psk_validator,
|
||||||
|
mimicry: MimicryEngine::new(),
|
||||||
|
cipher: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to OSTP server with silent handshake
|
||||||
|
pub async fn connect(&mut self) -> anyhow::Result<TcpStream> {
|
||||||
|
let mut stream = TcpStream::connect(self.config.server_addr).await?;
|
||||||
|
tracing::info!("Connected to {}", self.config.server_addr);
|
||||||
|
|
||||||
|
// Phase 1: Generate keypair and send PSK-signed handshake
|
||||||
|
let kex = KeyExchange::new();
|
||||||
|
let client_pubkey = *kex.public_key();
|
||||||
|
|
||||||
|
// Sign the public key with PSK
|
||||||
|
let signature = self.psk_validator.sign(client_pubkey.as_bytes());
|
||||||
|
|
||||||
|
// Send: [32-byte signature][32-byte pubkey]
|
||||||
|
stream.write_all(&signature).await?;
|
||||||
|
stream.write_all(client_pubkey.as_bytes()).await?;
|
||||||
|
|
||||||
|
// Phase 2: Receive server's response
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = stream.read_exact(&mut buf).await?;
|
||||||
|
if n < 64 {
|
||||||
|
anyhow::bail!("Invalid server response");
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_sig: [u8; 32] = buf[..32].try_into()?;
|
||||||
|
let server_pubkey_bytes: [u8; 32] = buf[32..64].try_into()?;
|
||||||
|
|
||||||
|
// Verify server's signature
|
||||||
|
if !self.psk_validator.verify(&server_pubkey_bytes, &server_sig) {
|
||||||
|
anyhow::bail!("Server PSK verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_pubkey = x25519_dalek::PublicKey::from(server_pubkey_bytes);
|
||||||
|
let shared_secret = kex.derive_shared(&server_pubkey);
|
||||||
|
|
||||||
|
// Derive session key
|
||||||
|
let session_key = crate::crypto::derive_session_key(&shared_secret, b"ostp-session-v1");
|
||||||
|
self.cipher = Some(AeadCipher::new(&session_key));
|
||||||
|
|
||||||
|
tracing::info!("Session established with server");
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send encrypted data through the tunnel
|
||||||
|
pub async fn send(&mut self, stream: &mut TcpStream, data: &[u8]) -> anyhow::Result<()> {
|
||||||
|
let cipher = self.cipher.as_mut().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
|
let encrypted = cipher.encrypt(data).map_err(|_| anyhow::anyhow!("Encryption failed"))?;
|
||||||
|
let padding = rand::random::<usize>() % 64; // Random padding 0-63 bytes
|
||||||
|
let frame = encode_frame(&encrypted, FrameFlags::DATA, padding)?;
|
||||||
|
|
||||||
|
stream.write_all(&frame).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive and decrypt data from the tunnel
|
||||||
|
pub async fn recv(&mut self, stream: &mut TcpStream) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let cipher = self.cipher.as_ref().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
anyhow::bail!("Connection closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut read_buf = BytesMut::from(&buf[..n]);
|
||||||
|
|
||||||
|
if let Some((data, _flags)) = decode_frame(&mut read_buf)? {
|
||||||
|
let plaintext = cipher.decrypt(&data).map_err(|_| anyhow::anyhow!("Decryption failed"))?;
|
||||||
|
Ok(plaintext)
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Incomplete frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SNI for current geo context (for external TLS wrapper if needed)
|
||||||
|
pub fn suggested_sni(&self) -> Option<&str> {
|
||||||
|
self.mimicry.random_sni(&self.config.country_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build TLS ClientHello for the selected SNI
|
||||||
|
pub fn build_tls_hello(&self) -> Option<Vec<u8>> {
|
||||||
|
let sni = self.suggested_sni()?;
|
||||||
|
Some(TlsHelloBuilder::new(sni).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_config() {
|
||||||
|
let addr: SocketAddr = "127.0.0.1:8443".parse().unwrap();
|
||||||
|
let psk = [0x42u8; 32];
|
||||||
|
let config = ClientConfig::new(addr, psk, "RU");
|
||||||
|
assert_eq!(config.country_code, "RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sni_selection() {
|
||||||
|
let addr: SocketAddr = "127.0.0.1:8443".parse().unwrap();
|
||||||
|
let config = ClientConfig::new(addr, [0u8; 32], "RU");
|
||||||
|
let client = OstpClient::new(config);
|
||||||
|
|
||||||
|
let sni = client.suggested_sni();
|
||||||
|
assert!(sni.is_some());
|
||||||
|
// Should be one of the Russian domains
|
||||||
|
let sni = sni.unwrap();
|
||||||
|
assert!(sni.ends_with(".ru"));
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ostp/src/crypto.rs
Normal file
149
ostp/src/crypto.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//! OSTP Crypto Module - PSK, X25519, ChaCha20-Poly1305
|
||||||
|
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit, OsRng},
|
||||||
|
ChaCha20Poly1305, Nonce,
|
||||||
|
};
|
||||||
|
use hmac::{digest::KeyInit as HmacKeyInit, Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||||
|
|
||||||
|
/// AEAD error type re-export
|
||||||
|
pub type AeadError = chacha20poly1305::aead::Error;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// PSK-based silent handshake validator
|
||||||
|
pub struct PskValidator {
|
||||||
|
psk: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PskValidator {
|
||||||
|
pub fn new(psk: [u8; 32]) -> Self {
|
||||||
|
Self { psk }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate HMAC signature for packet authentication
|
||||||
|
pub fn sign(&self, data: &[u8]) -> [u8; 32] {
|
||||||
|
let mut mac: HmacSha256 = HmacKeyInit::new_from_slice(&self.psk).expect("HMAC key size");
|
||||||
|
mac.update(data);
|
||||||
|
mac.finalize().into_bytes().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify packet has valid PSK-derived signature (silent drop if invalid)
|
||||||
|
pub fn verify(&self, data: &[u8], signature: &[u8; 32]) -> bool {
|
||||||
|
let mut mac: HmacSha256 = HmacKeyInit::new_from_slice(&self.psk).expect("HMAC key size");
|
||||||
|
mac.update(data);
|
||||||
|
mac.verify_slice(signature).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X25519 key exchange
|
||||||
|
pub struct KeyExchange {
|
||||||
|
secret: EphemeralSecret,
|
||||||
|
public: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyExchange {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let secret = EphemeralSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
Self { secret, public }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> &PublicKey {
|
||||||
|
&self.public
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_shared(self, peer_public: &PublicKey) -> SharedSecret {
|
||||||
|
self.secret.diffie_hellman(peer_public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyExchange {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AEAD cipher for data encryption
|
||||||
|
pub struct AeadCipher {
|
||||||
|
cipher: ChaCha20Poly1305,
|
||||||
|
nonce_counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AeadCipher {
|
||||||
|
pub fn new(key: &[u8; 32]) -> Self {
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(key).expect("key size");
|
||||||
|
Self {
|
||||||
|
cipher,
|
||||||
|
nonce_counter: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_nonce(&mut self) -> Nonce {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[4..12].copy_from_slice(&self.nonce_counter.to_le_bytes());
|
||||||
|
self.nonce_counter = self.nonce_counter.wrapping_add(1);
|
||||||
|
Nonce::from(nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, AeadError> {
|
||||||
|
let nonce = self.next_nonce();
|
||||||
|
let mut ciphertext = self.cipher.encrypt(&nonce, plaintext)?;
|
||||||
|
// Prepend nonce for decryption
|
||||||
|
let mut output = nonce.to_vec();
|
||||||
|
output.append(&mut ciphertext);
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, AeadError> {
|
||||||
|
if ciphertext.len() < 12 {
|
||||||
|
return Err(chacha20poly1305::aead::Error);
|
||||||
|
}
|
||||||
|
let nonce = Nonce::from_slice(&ciphertext[..12]);
|
||||||
|
self.cipher.decrypt(nonce, &ciphertext[12..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive session key from shared secret using HKDF-like expansion
|
||||||
|
pub fn derive_session_key(shared: &SharedSecret, salt: &[u8]) -> [u8; 32] {
|
||||||
|
let mut mac: HmacSha256 = HmacKeyInit::new_from_slice(salt).expect("HMAC key size");
|
||||||
|
mac.update(shared.as_bytes());
|
||||||
|
mac.finalize().into_bytes().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_psk_sign_verify() {
|
||||||
|
let psk = [0x42u8; 32];
|
||||||
|
let validator = PskValidator::new(psk);
|
||||||
|
let data = b"hello";
|
||||||
|
let sig = validator.sign(data);
|
||||||
|
assert!(validator.verify(data, &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_exchange() {
|
||||||
|
let alice = KeyExchange::new();
|
||||||
|
let bob = KeyExchange::new();
|
||||||
|
let alice_pub = *alice.public_key();
|
||||||
|
let bob_pub = *bob.public_key();
|
||||||
|
let alice_shared = alice.derive_shared(&bob_pub);
|
||||||
|
let bob_shared = bob.derive_shared(&alice_pub);
|
||||||
|
assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aead_encrypt_decrypt() {
|
||||||
|
let key = [0x11u8; 32];
|
||||||
|
let mut cipher = AeadCipher::new(&key);
|
||||||
|
let plaintext = b"secret message";
|
||||||
|
let ciphertext = cipher.encrypt(plaintext).unwrap();
|
||||||
|
let decrypted = cipher.decrypt(&ciphertext).unwrap();
|
||||||
|
assert_eq!(plaintext.as_slice(), decrypted.as_slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ostp/src/lib.rs
Normal file
11
ostp/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod mimicry;
|
||||||
|
pub mod server;
|
||||||
|
pub mod uot;
|
||||||
|
|
||||||
|
pub use client::{ClientConfig, OstpClient};
|
||||||
|
pub use crypto::{AeadCipher, KeyExchange, PskValidator};
|
||||||
|
pub use mimicry::{MimicryEngine, TlsHelloBuilder};
|
||||||
|
pub use server::{OstpServer, ServerConfig};
|
||||||
|
pub use uot::{decode_frame, encode_frame, FrameFlags, Fragmenter, Reassembler};
|
||||||
174
ostp/src/mimicry.rs
Normal file
174
ostp/src/mimicry.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//! Dynamic SNI & TLS Mimicry Engine
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Geo-based SNI target selection
|
||||||
|
pub struct MimicryEngine {
|
||||||
|
geo_sni_map: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MimicryEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut geo_sni_map = HashMap::new();
|
||||||
|
|
||||||
|
// Default geo-SNI mappings for contextual mimicry
|
||||||
|
geo_sni_map.insert(
|
||||||
|
"RU".into(),
|
||||||
|
vec![
|
||||||
|
"gosuslugi.ru".into(),
|
||||||
|
"sberbank.ru".into(),
|
||||||
|
"yandex.ru".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
geo_sni_map.insert(
|
||||||
|
"NO".into(),
|
||||||
|
vec!["bankid.no".into(), "vipps.no".into(), "altinn.no".into()],
|
||||||
|
);
|
||||||
|
geo_sni_map.insert(
|
||||||
|
"DE".into(),
|
||||||
|
vec![
|
||||||
|
"sparkasse.de".into(),
|
||||||
|
"deutsche-bank.de".into(),
|
||||||
|
"bund.de".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
geo_sni_map.insert(
|
||||||
|
"US".into(),
|
||||||
|
vec![
|
||||||
|
"apple.com".into(),
|
||||||
|
"microsoft.com".into(),
|
||||||
|
"amazon.com".into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
geo_sni_map.insert(
|
||||||
|
"CN".into(),
|
||||||
|
vec!["qq.com".into(), "baidu.com".into(), "taobao.com".into()],
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { geo_sni_map }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select SNI based on geo-location
|
||||||
|
pub fn select_sni(&self, country_code: &str) -> Option<&str> {
|
||||||
|
self.geo_sni_map
|
||||||
|
.get(country_code)
|
||||||
|
.and_then(|list| list.first().map(|s| s.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get random SNI from geo list for anti-fingerprinting
|
||||||
|
pub fn random_sni(&self, country_code: &str) -> Option<&str> {
|
||||||
|
self.geo_sni_map.get(country_code).and_then(|list| {
|
||||||
|
if list.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let idx = rand::random::<usize>() % list.len();
|
||||||
|
Some(list[idx].as_str())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add custom geo-SNI mapping
|
||||||
|
pub fn add_mapping(&mut self, country: String, domains: Vec<String>) {
|
||||||
|
self.geo_sni_map.insert(country, domains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MimicryEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS ClientHello builder for REALITY-like mimicry
|
||||||
|
pub struct TlsHelloBuilder {
|
||||||
|
sni: String,
|
||||||
|
random_session_id: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsHelloBuilder {
|
||||||
|
pub fn new(sni: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
sni: sni.into(),
|
||||||
|
random_session_id: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build minimal TLS 1.3 ClientHello-like header
|
||||||
|
pub fn build(&self) -> Vec<u8> {
|
||||||
|
let mut hello = Vec::with_capacity(256);
|
||||||
|
|
||||||
|
// TLS record header
|
||||||
|
hello.push(0x16); // Handshake
|
||||||
|
hello.extend_from_slice(&[0x03, 0x01]); // TLS 1.0 for compat
|
||||||
|
|
||||||
|
// Placeholder for length (will update)
|
||||||
|
let len_pos = hello.len();
|
||||||
|
hello.extend_from_slice(&[0x00, 0x00]);
|
||||||
|
|
||||||
|
// Handshake header
|
||||||
|
hello.push(0x01); // ClientHello
|
||||||
|
let hs_len_pos = hello.len();
|
||||||
|
hello.extend_from_slice(&[0x00, 0x00, 0x00]); // length placeholder
|
||||||
|
|
||||||
|
// Client version (TLS 1.2 presented, 1.3 in extensions)
|
||||||
|
hello.extend_from_slice(&[0x03, 0x03]);
|
||||||
|
|
||||||
|
// Random (32 bytes)
|
||||||
|
let random: [u8; 32] = rand::random();
|
||||||
|
hello.extend_from_slice(&random);
|
||||||
|
|
||||||
|
// Session ID (32 bytes if random)
|
||||||
|
if self.random_session_id {
|
||||||
|
hello.push(32);
|
||||||
|
let session_id: [u8; 32] = rand::random();
|
||||||
|
hello.extend_from_slice(&session_id);
|
||||||
|
} else {
|
||||||
|
hello.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher suites (TLS 1.3 suites)
|
||||||
|
hello.extend_from_slice(&[0x00, 0x06]); // 3 suites
|
||||||
|
hello.extend_from_slice(&[0x13, 0x01]); // TLS_AES_128_GCM_SHA256
|
||||||
|
hello.extend_from_slice(&[0x13, 0x02]); // TLS_AES_256_GCM_SHA384
|
||||||
|
hello.extend_from_slice(&[0x13, 0x03]); // TLS_CHACHA20_POLY1305_SHA256
|
||||||
|
|
||||||
|
// Compression (null)
|
||||||
|
hello.extend_from_slice(&[0x01, 0x00]);
|
||||||
|
|
||||||
|
// Extensions with SNI
|
||||||
|
let ext_start = hello.len();
|
||||||
|
hello.extend_from_slice(&[0x00, 0x00]); // ext length placeholder
|
||||||
|
|
||||||
|
// SNI extension
|
||||||
|
hello.extend_from_slice(&[0x00, 0x00]); // type: server_name
|
||||||
|
let sni_bytes = self.sni.as_bytes();
|
||||||
|
let sni_ext_len = sni_bytes.len() + 5;
|
||||||
|
hello.extend_from_slice(&(sni_ext_len as u16).to_be_bytes());
|
||||||
|
hello.extend_from_slice(&((sni_bytes.len() + 3) as u16).to_be_bytes());
|
||||||
|
hello.push(0x00); // host_name type
|
||||||
|
hello.extend_from_slice(&(sni_bytes.len() as u16).to_be_bytes());
|
||||||
|
hello.extend_from_slice(sni_bytes);
|
||||||
|
|
||||||
|
// supported_versions extension
|
||||||
|
hello.extend_from_slice(&[0x00, 0x2b]); // type
|
||||||
|
hello.extend_from_slice(&[0x00, 0x03]); // length
|
||||||
|
hello.push(0x02); // versions length
|
||||||
|
hello.extend_from_slice(&[0x03, 0x04]); // TLS 1.3
|
||||||
|
|
||||||
|
// Update lengths
|
||||||
|
let ext_len = hello.len() - ext_start - 2;
|
||||||
|
hello[ext_start] = (ext_len >> 8) as u8;
|
||||||
|
hello[ext_start + 1] = ext_len as u8;
|
||||||
|
|
||||||
|
let total_len = hello.len() - 5;
|
||||||
|
hello[len_pos] = (total_len >> 8) as u8;
|
||||||
|
hello[len_pos + 1] = total_len as u8;
|
||||||
|
|
||||||
|
let hs_len = hello.len() - hs_len_pos - 3;
|
||||||
|
hello[hs_len_pos] = 0;
|
||||||
|
hello[hs_len_pos + 1] = (hs_len >> 8) as u8;
|
||||||
|
hello[hs_len_pos + 2] = hs_len as u8;
|
||||||
|
|
||||||
|
hello
|
||||||
|
}
|
||||||
|
}
|
||||||
192
ostp/src/server.rs
Normal file
192
ostp/src/server.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
//! OSTP Server - Stealth relay endpoint
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::crypto::{AeadCipher, KeyExchange, PskValidator};
|
||||||
|
use crate::uot::decode_frame;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
|
||||||
|
/// Server configuration
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
pub psk: [u8; 32],
|
||||||
|
pub max_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
pub fn new(addr: SocketAddr, psk: [u8; 32]) -> Self {
|
||||||
|
Self {
|
||||||
|
listen_addr: addr,
|
||||||
|
psk,
|
||||||
|
max_connections: 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active connection state
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct Connection {
|
||||||
|
cipher: AeadCipher,
|
||||||
|
user_id: Option<uuid::Uuid>,
|
||||||
|
bytes_rx: u64,
|
||||||
|
bytes_tx: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSTP Server with silent handshake
|
||||||
|
pub struct OstpServer {
|
||||||
|
config: ServerConfig,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
psk_validator: PskValidator,
|
||||||
|
connections: Arc<RwLock<HashMap<SocketAddr, Connection>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OstpServer {
|
||||||
|
pub fn new(config: ServerConfig) -> Self {
|
||||||
|
let psk_validator = PskValidator::new(config.psk);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
psk_validator,
|
||||||
|
connections: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the server (main async loop)
|
||||||
|
pub async fn run(&self) -> anyhow::Result<()> {
|
||||||
|
let listener = TcpListener::bind(self.config.listen_addr).await?;
|
||||||
|
tracing::info!("OSTP server listening on {}", self.config.listen_addr);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, addr)) => {
|
||||||
|
let psk_validator = PskValidator::new(self.config.psk);
|
||||||
|
let connections = Arc::clone(&self.connections);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = Self::handle_connection(stream, addr, psk_validator, connections).await {
|
||||||
|
tracing::debug!("Connection {} closed: {}", addr, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Accept error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
addr: SocketAddr,
|
||||||
|
psk_validator: PskValidator,
|
||||||
|
connections: Arc<RwLock<HashMap<SocketAddr, Connection>>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
|
||||||
|
// Phase 1: Silent PSK handshake (32-byte signature must be first)
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n < 64 {
|
||||||
|
// Silent drop - don't reveal port is open
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let signature: [u8; 32] = buf[..32].try_into()?;
|
||||||
|
let payload = &buf[32..n];
|
||||||
|
|
||||||
|
if !psk_validator.verify(payload, &signature) {
|
||||||
|
// Silent drop on invalid PSK - core anti-probing defense
|
||||||
|
tracing::trace!("Silent drop for {}: invalid PSK", addr);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: X25519 key exchange
|
||||||
|
// Payload contains client's public key (32 bytes)
|
||||||
|
if payload.len() < 32 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_pubkey_bytes: [u8; 32] = payload[..32].try_into()?;
|
||||||
|
let client_pubkey = x25519_dalek::PublicKey::from(client_pubkey_bytes);
|
||||||
|
|
||||||
|
let server_kex = KeyExchange::new();
|
||||||
|
let server_pubkey = *server_kex.public_key();
|
||||||
|
let shared_secret = server_kex.derive_shared(&client_pubkey);
|
||||||
|
|
||||||
|
// Derive session key
|
||||||
|
let session_key = crate::crypto::derive_session_key(&shared_secret, b"ostp-session-v1");
|
||||||
|
let cipher = AeadCipher::new(&session_key);
|
||||||
|
|
||||||
|
// Send server's public key (signed with PSK)
|
||||||
|
let server_response = server_pubkey.as_bytes();
|
||||||
|
let response_sig = psk_validator.sign(server_response);
|
||||||
|
stream.write_all(&response_sig).await?;
|
||||||
|
stream.write_all(server_response).await?;
|
||||||
|
|
||||||
|
// Store connection
|
||||||
|
{
|
||||||
|
let mut conns = connections.write().await;
|
||||||
|
conns.insert(addr, Connection {
|
||||||
|
cipher,
|
||||||
|
user_id: None,
|
||||||
|
bytes_rx: 0,
|
||||||
|
bytes_tx: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Session established with {}", addr);
|
||||||
|
|
||||||
|
// Phase 3: Encrypted relay loop
|
||||||
|
let mut read_buf = BytesMut::with_capacity(65536);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
read_buf.extend_from_slice(&buf[..n]);
|
||||||
|
|
||||||
|
// Process frames
|
||||||
|
while let Some((data, flags)) = decode_frame(&mut read_buf)? {
|
||||||
|
let conns = connections.read().await;
|
||||||
|
if let Some(conn) = conns.get(&addr) {
|
||||||
|
match conn.cipher.decrypt(&data) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
if flags.is_data() {
|
||||||
|
// TODO: Route decrypted data to destination
|
||||||
|
tracing::trace!("Received {} bytes from {}", plaintext.len(), addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Decrypt failed for {}", addr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
connections.write().await.remove(&addr);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_config() {
|
||||||
|
let addr: SocketAddr = "127.0.0.1:8443".parse().unwrap();
|
||||||
|
let psk = [0x42u8; 32];
|
||||||
|
let config = ServerConfig::new(addr, psk);
|
||||||
|
assert_eq!(config.listen_addr, addr);
|
||||||
|
assert_eq!(config.max_connections, 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
ostp/src/uot.rs
Normal file
132
ostp/src/uot.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! UoT (UDP-over-TCP) framing layer
|
||||||
|
|
||||||
|
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum UDP datagram size
|
||||||
|
pub const MAX_UDP_SIZE: usize = 65535;
|
||||||
|
/// Frame header: 2 bytes length + 1 byte flags
|
||||||
|
const HEADER_SIZE: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum FrameError {
|
||||||
|
#[error("frame too large: {0} bytes")]
|
||||||
|
TooLarge(usize),
|
||||||
|
#[error("incomplete frame")]
|
||||||
|
Incomplete,
|
||||||
|
#[error("invalid frame")]
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct FrameFlags(u8);
|
||||||
|
|
||||||
|
impl FrameFlags {
|
||||||
|
pub const DATA: Self = Self(0x00);
|
||||||
|
pub const CONTROL: Self = Self(0x01);
|
||||||
|
pub const PADDING: Self = Self(0x02);
|
||||||
|
|
||||||
|
pub fn is_data(self) -> bool {
|
||||||
|
self.0 == 0x00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encapsulate UDP datagram into framed format with random padding
|
||||||
|
pub fn encode_frame(data: &[u8], flags: FrameFlags, padding: usize) -> Result<Bytes, FrameError> {
|
||||||
|
let total_len = data.len() + padding;
|
||||||
|
if total_len > MAX_UDP_SIZE {
|
||||||
|
return Err(FrameError::TooLarge(total_len));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = BytesMut::with_capacity(HEADER_SIZE + total_len);
|
||||||
|
buf.put_u16(total_len as u16);
|
||||||
|
buf.put_u8(flags.0);
|
||||||
|
buf.put_slice(data);
|
||||||
|
|
||||||
|
// Random padding to avoid fingerprinting
|
||||||
|
if padding > 0 {
|
||||||
|
let pad: Vec<u8> = (0..padding).map(|_| rand::random()).collect();
|
||||||
|
buf.put_slice(&pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buf.freeze())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode frame from buffer, returns (payload, data_length, flags)
|
||||||
|
pub fn decode_frame(buf: &mut BytesMut) -> Result<Option<(Bytes, FrameFlags)>, FrameError> {
|
||||||
|
if buf.len() < HEADER_SIZE {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||||
|
let flags = FrameFlags(buf[2]);
|
||||||
|
|
||||||
|
if buf.len() < HEADER_SIZE + len {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.advance(HEADER_SIZE);
|
||||||
|
let data = buf.split_to(len).freeze();
|
||||||
|
|
||||||
|
Ok(Some((data, flags)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MTU-aware fragmentation
|
||||||
|
pub struct Fragmenter {
|
||||||
|
mtu: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fragmenter {
|
||||||
|
pub fn new(mtu: usize) -> Self {
|
||||||
|
Self { mtu }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fragment(&self, data: &[u8]) -> Vec<Bytes> {
|
||||||
|
data.chunks(self.mtu)
|
||||||
|
.map(|chunk| Bytes::copy_from_slice(chunk))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reassembly buffer for fragmented packets
|
||||||
|
pub struct Reassembler {
|
||||||
|
buffer: BytesMut,
|
||||||
|
expected_len: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reassembler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: BytesMut::new(),
|
||||||
|
expected_len: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, fragment: &[u8], total_len: usize) {
|
||||||
|
if self.expected_len.is_none() {
|
||||||
|
self.expected_len = Some(total_len);
|
||||||
|
}
|
||||||
|
self.buffer.extend_from_slice(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.expected_len
|
||||||
|
.map(|len| self.buffer.len() >= len)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take(&mut self) -> Option<Bytes> {
|
||||||
|
if self.is_complete() {
|
||||||
|
self.expected_len = None;
|
||||||
|
Some(self.buffer.split().freeze())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Reassembler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user