Challenge

This commit is contained in:
Pascal Engélibert 2025-03-31 22:29:43 +02:00
commit e090b70dba
7 changed files with 416 additions and 62 deletions

157
Cargo.lock generated
View file

@ -41,6 +41,21 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "block-buffer"
version = "0.11.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a229bfd78e4827c91b9b95784f69492c1b77c1ab75a45a8a037b139215086f94"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bytes"
version = "1.10.0"
@ -53,6 +68,24 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.2.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170d71b5b14dec99db7739f6fc7d6ec2db80b78c3acb77db48392ccc3d8a9ea0"
dependencies = [
"hybrid-array",
]
[[package]]
name = "daemonize"
version = "0.5.0"
@ -62,12 +95,51 @@ dependencies = [
"libc",
]
[[package]]
name = "digest"
version = "0.11.0-pre.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c478574b20020306f98d61c8ca3322d762e1ff08117422ac6106438605ea516"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "hybrid-array"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dab50e193aebe510fe0e40230145820e02f48dae0cf339ea4204e6e708ff7bd"
dependencies = [
"typenum",
]
[[package]]
name = "keccak"
version = "0.2.0-pre.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cdd4f0dc5807b9a2b25dd48a3f58e862606fe7bd47f41ecde36e97422d7e90"
dependencies = [
"cpufeatures",
]
[[package]]
name = "libc"
version = "0.2.171"
@ -84,10 +156,14 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
name = "mesozoa"
version = "0.1.0"
dependencies = [
"base64",
"rand",
"realm_io",
"realm_syscall",
"regex",
"sha3",
"static_cell",
"subtle",
"tokio",
]
@ -132,6 +208,15 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.94"
@ -150,6 +235,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "realm_io"
version = "0.5.1"
@ -207,6 +322,16 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "sha3"
version = "0.11.0-pre.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bc997d7a5fa67cc1e352b2001124d28edb948b4e7a16567f9b3c1e51952524"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "socket2"
version = "0.5.8"
@ -226,6 +351,12 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.100"
@ -264,6 +395,12 @@ dependencies = [
"syn",
]
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -348,3 +485,23 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View file

@ -4,8 +4,13 @@ version = "0.1.0"
edition = "2024"
[dependencies]
base64 = "0.22.1"
rand = "0.8.5"
realm_io = { version = "0.5" }
realm_syscall = "0.1"
regex = { version = "1.11", default-features = false, features = ["perf", "std"] }
# TODO test feature asm on ARM
sha3 = { version = "0.11.0-pre.5", default-features = false, features = ["std"] }
static_cell = { version = "2.1.0", features = ["nightly"] }
subtle = { version = "2.6.1", default-features = false, features = ["const-generics", "nightly", "std"] }
tokio = { version = "1", features = ["io-util", "macros", "rt", "rt-multi-thread", "time"] }

View file

@ -1,5 +1,45 @@
Mesozoa
# Mesozoa
Why not Anubis? Because it provides no build instructions and only supports Docker.
Why not using Realm completely? Because the hook system is useless and only allows filtering.
Why not using Realm completely? Because the hook system is useless and only allows filtering.
## Install
Must be used behind a reverse proxy providing `X-Forwarded-For`.
## Challenge protocol
### Challenge generation
Sent by the server as a cookie.
`secret <- chosen randomly, long term`
`salt <- chosen randomly, not stored`
`timestamp <- UNIX time in seconds, 64 bits, big endian`
`ua <- User-Agent from request header`
`ip <- X-Forwarded-For from request header (client's IP)`
`set-cookie: mesozoa-challenge=BASE64(salt || timestamp || SHA3-256(secret || salt || timestamp || ip || "/" || ua))`
Where `BASE64` is unpadded.
### Challenge verification
Request must contain both cookies `mesozoa-challenge` and `mesozoa-proof`.
## Security
### Network handling and HTTP parsing
This implementation uses cheap tricks and regexes, is probably not fully compliant to HTTP specs, etc.
You should probably not expose it directly to an open network.
Please use it behind a safer reverse proxy like Apache or Nginx.
### Length-extension attack
SHA3 (used as a MAC in the challenge cookie) is not vulnerable. Values in the hash are either fixed-length, safe, or delimited.

51
src/challenge.rs Normal file
View file

@ -0,0 +1,51 @@
use base64::Engine;
use sha3::{Digest};
use subtle::ConstantTimeEq;
use crate::{CHALLENGE_TIMEOUT, MAC_LEN, SALT_LEN, SECRET_LEN};
pub fn check_challenge() -> bool {
false
}
pub fn compute_challenge_mac(secret: &[u8; SECRET_LEN], salt: &[u8; SALT_LEN], timestamp: [u8; 8], ip: &[u8], user_agent: &[u8]) -> [u8; MAC_LEN] {
let mut hasher = sha3::Sha3_256::default();
hasher.update(secret);
hasher.update(salt);
hasher.update(&timestamp);
hasher.update(ip);
hasher.update(b"/");
hasher.update(user_agent);
hasher.finalize().into()
}
pub fn format_challenge_cookie(salt: &[u8; SALT_LEN], timestamp: [u8; 8], mac: &[u8; MAC_LEN]) -> String {
let mut buf = Vec::new();
buf.extend_from_slice(salt);
buf.extend_from_slice(&timestamp);
buf.extend_from_slice(mac);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
pub fn verify_challenge_cookie(cookie: &[u8], secret: &[u8; SECRET_LEN], user_agent: &[u8], ip: &[u8]) -> bool {
let Ok(cookie_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(cookie) else {
dbg!("invalid base64");
return false;
};
if cookie_bytes.len() != SALT_LEN + 8 + MAC_LEN {
dbg!("invalid len");
return false;
}
let timestamp: [u8; 8] = cookie_bytes[SALT_LEN..SALT_LEN+8].try_into().unwrap();
let timestamp_time = u64::from_be_bytes(timestamp);
if timestamp_time.wrapping_add(CHALLENGE_TIMEOUT) < std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap().as_secs() {
dbg!("invalid time");
return false;
}
let salt: [u8; SALT_LEN] = cookie_bytes[0..SALT_LEN].try_into().unwrap();
let mac: [u8; MAC_LEN] = cookie_bytes[SALT_LEN + 8..SALT_LEN + 8 + MAC_LEN].try_into().unwrap();
let expected_mac = compute_challenge_mac(secret, &salt, timestamp, ip, user_agent);
expected_mac.ct_eq(&mac).into()
}

View file

@ -47,11 +47,9 @@ pub fn parse_cookies<'a>(line: &'a [u8]) -> Option<&'a [u8]> {
let mut iter = line.iter().enumerate().skip(7);
while let Some((i, c)) = iter.next() {
if *c == b' ' {
continue
}
if waiting_for_name {
continue;
}
if waiting_for_name {}
//iter.advance_by(5);
}
None

View file

@ -1,16 +1,23 @@
mod challenge;
mod http;
mod policy;
use http::HeaderLineIterator;
use policy::{CompiledPolicies, Policy};
use regex::bytes::Regex;
use rand::Rng;
use regex::{bytes::Regex, bytes::RegexSet};
use std::{
io::{BufReader, Write},
net::SocketAddr,
time::Duration,
io::{BufReader, Write}, net::SocketAddr, sync::atomic::ATOMIC_BOOL_INIT, time::Duration
};
use tokio::{io::{ReadBuf, AsyncWriteExt}, time::timeout};
use tokio::{
io::{AsyncWriteExt, ReadBuf}, net::TcpSocket, time::timeout
};
const SALT_LEN: usize = 16;
const SECRET_LEN: usize = 32;
const MAC_LEN: usize = 32;
const CHALLENGE_TIMEOUT: u64 = 3600;
static CHALLENGE_BODY: &str = include_str!("challenge.html");
@ -25,17 +32,31 @@ macro_rules! mk_static {
#[tokio::main]
async fn main() {
let listen_addr = "127.0.0.1:8000".parse().unwrap();
let mut rng = rand::thread_rng();
let listen_addr: SocketAddr = "127.0.0.1:8504".parse().unwrap();
let pass_addr: SocketAddr = "127.0.0.1:80".parse().unwrap();
let policy_groups = &[&[Policy {
name: String::from("Block"),
filter: policy::Filter::FirstLineMatch(String::from("GET /block")),
action: policy::Action::Drop,
priority: 0,
}]];
let secret: [u8; SECRET_LEN] = rng.r#gen();
let challenge_response = &*mk_static!(String, format!("HTTP/1.1 200\r\ncontent-type: text/html\r\ncontent-length: {}\r\n\r\n{}", CHALLENGE_BODY.len(), CHALLENGE_BODY));
let challenge_response = &*mk_static!(
String,
format!(
"HTTP/1.1 200\r\ncontent-type: text/html\r\ncontent-length: {}\r\n\r\n{}",
CHALLENGE_BODY.len(),
CHALLENGE_BODY
)
);
let policy_groups: Vec<CompiledPolicies> = policy_groups.into_iter().map(|policies| CompiledPolicies::new(*policies)).collect();
let policy_groups: Vec<CompiledPolicies> = policy_groups
.into_iter()
.map(|policies| CompiledPolicies::new(*policies))
.collect();
let socket = realm_syscall::new_tcp_socket(&listen_addr).unwrap();
@ -46,50 +67,120 @@ async fn main() {
let listener = tokio::net::TcpListener::from_std(socket.into()).unwrap();
let cookie_regex = Regex::new(r"^Cookie: *(?:[^;=]+=[^;=]* *; *)*mesozoa *= *([0-9a-zA-Z]{4})").unwrap();
let proof_regex =
Regex::new(r"^Cookie: *(?:[^;=]+=[^;=]* *; *)*mesozoa-proof *= *([0-9a-zA-Z_-]{4})").unwrap();
let challenge_regex =
Regex::new(r"^Cookie: *(?:[^;=]+=[^;=]* *; *)*mesozoa-challenge *= *([0-9a-zA-Z_-]{75})").unwrap();
let ip_regex =
Regex::new(r"^X-Forwarded-For: *([a-fA-F0-9.:]+)$").unwrap();
let user_agent_regex =
Regex::new(r"^User-Agent: *([a-zA-Z0-9.,:;/ _()-]+)$").unwrap();
loop {
let Ok((mut client_stream, client_addr)) = listener.accept().await else {
continue;
};
//client_stream.set_nodelay(true).ok();
loop {
let Ok((mut client_stream, client_addr)) = listener.accept().await else {
continue;
};
//client_stream.set_nodelay(true).ok();
let cookie_regex = cookie_regex.clone();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let mut buf_reader = ReadBuf::new(&mut buf);
if let Err(_) = timeout(
Duration::from_millis(100),
std::future::poll_fn(|cx| client_stream.poll_peek(cx, &mut buf_reader)),
)
.await
{
println!("peek timeout");
return;
}
let mut header_line_iter = HeaderLineIterator::new(&buf);
let Some(first_line) = header_line_iter.next() else {
println!("Not HTTP, or too long line");
return;
};
// TODO matching
// for test we will challenge everything!
if let Some(captures) = header_line_iter.find_map(|line| cookie_regex.captures(line)) {
if let Some(cookie) = captures.get(1) {
let mut stdout = std::io::stdout();
stdout.write_all(cookie.as_bytes()).unwrap();
stdout.flush().unwrap();
println!("");
} else {
println!("cookie header, but no cookie")
}
} else {
println!("no cookie");
}
client_stream.writable().await.unwrap();
client_stream.write_all(challenge_response.as_bytes()).await.unwrap();
});
let proof_regex = proof_regex.clone();
let challenge_regex = challenge_regex.clone();
let ip_regex = ip_regex.clone();
let user_agent_regex = user_agent_regex.clone();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let mut buf_reader = ReadBuf::new(&mut buf);
if let Err(_) = timeout(
Duration::from_millis(100),
std::future::poll_fn(|cx| client_stream.poll_peek(cx, &mut buf_reader)),
)
.await
{
println!("peek timeout");
return;
}
let mut stdout = std::io::stdout();
stdout.write_all(&buf).unwrap();
stdout.flush().unwrap();
println!("");
let mut header_line_iter = HeaderLineIterator::new(&buf);
let Some(first_line) = header_line_iter.next() else {
println!("Not HTTP, or too long line");
return;
};
// TODO matching
// for test we will challenge everything!
let mut req_challenge = None;
let mut req_proof = None;
let mut req_user_agent: &[u8] = &[];
let mut req_ip: &[u8] = &[];
for line in header_line_iter {
if let Some(Some(m)) = challenge_regex.captures(line).map(|c| c.get(1)) {
req_challenge = Some(m.as_bytes());
}
if let Some(Some(m)) = proof_regex.captures(line).map(|c| c.get(1)) {
req_proof = Some(m.as_bytes());
}
if let Some(Some(m)) = user_agent_regex.captures(line).map(|c| c.get(1)) {
req_user_agent = m.as_bytes();
}
if let Some(Some(m)) = ip_regex.captures(line).map(|c| c.get(1)) {
req_ip = m.as_bytes();
}
}
let mut allow = false;
if let Some(req_challenge) = req_challenge {
allow = challenge::verify_challenge_cookie(req_challenge, &secret, req_user_agent, req_ip);
}
if allow {
// TODO reuse connections
let pass_socket = realm_syscall::new_tcp_socket(&pass_addr).unwrap();
pass_socket.set_reuse_address(true).ok();
let pass_socket = TcpSocket::from_std_stream(pass_socket.into());
let mut pass_stream = pass_socket.connect(pass_addr).await.unwrap();
match realm_io::bidi_zero_copy(&mut client_stream, &mut pass_stream).await {
Ok(_) => {},
Err(ref e) if e.kind() == tokio::io::ErrorKind::InvalidInput => {
realm_io::bidi_copy(&mut client_stream, &mut pass_stream).await.unwrap();
}
Err(e) => panic!("err {}", e),
}
} else {
let salt: [u8; SALT_LEN] = rand::thread_rng().r#gen();
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let timestamp_bytes = timestamp.to_be_bytes();
let challenge_mac = challenge::compute_challenge_mac(&secret, &salt, timestamp_bytes, req_ip, req_user_agent);
let challenge_cookie = challenge::format_challenge_cookie(&salt, timestamp_bytes, &challenge_mac);
let response = format!(
"HTTP/1.1 200\r\n\
content-type: text/html\r\n\
content-length: {}\r\n\
set-cookie: mesozoa-challenge={}; max-age=3600; samesite=strict\r\n\
\r\n\
{}",
CHALLENGE_BODY.len(),
challenge_cookie,
CHALLENGE_BODY,
);
client_stream.writable().await.unwrap();
client_stream
.write_all(response.as_bytes())
.await
.unwrap();
}
});
}
}

View file

@ -103,21 +103,33 @@ impl CompiledPolicies {
}
CompiledPolicies {
first_line_regex_set: if first_line_regexes.is_empty() {None} else {Some(RegexSet::new(&first_line_regexes).unwrap())},
header_line_regex_set: if header_line_regexes.is_empty() {None} else {Some(RegexSet::new(&header_line_regexes).unwrap())},
first_line_regex_set: if first_line_regexes.is_empty() {
None
} else {
Some(RegexSet::new(&first_line_regexes).unwrap())
},
header_line_regex_set: if header_line_regexes.is_empty() {
None
} else {
Some(RegexSet::new(&header_line_regexes).unwrap())
},
policies: compiled_policies,
}
}
pub fn evaluate<'a>(&self, mut header_lines: impl Iterator<Item=&'a [u8]>) -> Result<Option<&CompiledPolicy>, PolicyEvaluationError> {
pub fn evaluate<'a>(
&self,
mut header_lines: impl Iterator<Item = &'a [u8]>,
) -> Result<Option<&CompiledPolicy>, PolicyEvaluationError> {
let mut best_policy = None;
let mut best_priority = i32::MAX;
let first_line = header_lines.next().ok_or(PolicyEvaluationError::NoFirstLine)?;
let first_line = header_lines
.next()
.ok_or(PolicyEvaluationError::NoFirstLine)?;
if let Some(first_line_regex_set) = &self.first_line_regex_set {
//let matches = first_line_regex_set.matches(first_line);
}
Ok(best_policy)