Skip to content

Commit af5d69e

Browse files
authored
Merge pull request #17 from pseusys/feat/active-probing-resistance
Active probing full resistance functionality
2 parents 65b9cb0 + b770bb5 commit af5d69e

10 files changed

Lines changed: 294 additions & 55 deletions

File tree

PROTOCOL.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ flowchart LR
6767
CS --> CFM2["FlowManager → addr 2"]
6868
CSM --> CHCP["HealthCheckProvider"]
6969
CFM1 --> CDP1["DecoyProvider"]
70+
CFM1 --> CAPH1["ActiveProbeHandler"]
7071
CFM2 --> CDP2["DecoyProvider"]
72+
CFM2 --> CAPH2["ActiveProbeHandler"]
7173
end
7274
7375
subgraph Server
@@ -76,7 +78,9 @@ flowchart LR
7678
L --> SFM1["FlowManager :port 1"]
7779
L --> SFM2["FlowManager :port 2"]
7880
SFM1 --> SDP1["DecoyProvider (per user)"]
81+
SFM1 --> SAPH1["ActiveProbeHandler"]
7982
SFM2 --> SDP2["DecoyProvider (per user)"]
83+
SFM2 --> SAPH2["ActiveProbeHandler"]
8084
L --> SSM1["SessionManager (User A)"]
8185
L --> SSM2["SessionManager (User B)"]
8286
SSM1 --> SHCP1["HealthCheckProvider"]
@@ -162,6 +166,7 @@ The tailor is the only part of the message that should be decrypted by the flow
162166
It always starts at `packet_length - tailor_length - tailor_encryption_overhead` and ends at the end of the packet.
163167
If the data flag is set, the payload should be read starting from `packet_length - tailor_length - tailor_encryption_overhead - payload_length` and until the start of the tailor.
164168
If the decoy flag is set, the packet should be discarded right away by the flow manager.
169+
If tailor decryption or authentication fails, the raw (still-encrypted) packet is forwarded to the flow manager's [active probe handler](#active-probing-protection) instead of being silently dropped.
165170

166171
### Fake body
167172

@@ -230,10 +235,14 @@ sequenceDiagram
230235
231236
FM-->>FM: receive UDP datagram
232237
FM->>FM: Strip FakeHeader + FakeBody
233-
FM->>FM: Decrypt tailor → verify identity + PN
234-
FM-->>SM: deliver(body)
235-
SM->>SM: Decrypt body (session key)
236-
SM-->>App: recv() → plaintext payload
238+
alt tailor decryption / authentication ok
239+
FM->>FM: Decrypt tailor → verify identity + PN
240+
FM-->>SM: deliver(body)
241+
SM->>SM: Decrypt body (session key)
242+
SM-->>App: recv() → plaintext payload
243+
else tailor decryption / authentication fail
244+
FM->>FM: ActiveProbeHandler::process(raw packet)
245+
end
237246
```
238247

239248
The simplest pattern affects data packets: they are sent directly and completely, without any delays, jitter, splitting or combination - as soon as possible, providing maximum efficiency.
@@ -496,6 +505,23 @@ The subheader mode can have these values:
496505

497506
By default, subheader mode is chosen with equal probability for every option.
498507

508+
### Active probing protection
509+
510+
Active probing is a technique used by censors and network auditors to probe a suspected TYPHOON endpoint directly: they send crafted packets and observe whether the server responds in a way that reveals the protocol.
511+
To resist this, the flow manager must handle packets that fail tailor authentication gracefully rather than closing the socket or producing a distinctive error response.
512+
513+
Every flow manager has a single **active probe handler** (one per flow, not per user) that receives every packet the flow manager could not identify:
514+
515+
- **Tailor decryption or authentication failure**: any packet whose encrypted tailor cannot be deciphered or whose BLAKE3/AEAD authentication tag does not verify is forwarded to the handler. This covers both random garbage and deliberately crafted probe packets.
516+
- **Server only — unregistered user**: a non-handshake, non-decoy packet arriving with a valid-looking tailor but an identity not present in the registered-user table is also forwarded. This catches replayed or forged post-handshake packets sent by an active prober who has observed legitimate traffic.
517+
518+
The handler receives the raw (still encrypted) wire packet together with the UDP source address (`Some(addr)` on the server, `None` on the client whose socket is already connected to a fixed peer).
519+
It can choose to silently drop the packet, log it, or send a raw response through the flow manager socket using `ProbeFlowSender::send_raw` — completely bypassing TYPHOON framing — to mimic whatever protocol the operator wants to impersonate.
520+
521+
The default implementation (`NoopProbeHandler`) drops all unidentified packets silently.
522+
Custom handlers implement `ActiveProbeHandler<AE>` directly; the `start` method receives a `Weak<dyn ProbeFlowSender>` (for raw send access) and an `Arc<Settings<AE>>` so that expensive construction is deferred to startup and the hot `process` path stays allocation-free.
523+
Handler instances are created by a `ProbeFactory<AE>` — a simple no-arg closure stored in `ServerFlowConfiguration` or `ClientSocketBuilder` — and attached to the flow manager during `ServerFlowManager::new` / `ClientFlowManager::new`.
524+
499525
## Cryptography
500526

501527
The following requirements are taken into account for TYPHOON protocol cryptography suite selection:
@@ -596,17 +622,22 @@ flowchart TD
596622
A([UDP socket · recv]) --> B[Flow layer: strip FakeHeader ‖ FakeBody]
597623
B --> TM{Feature mode}
598624
TM -- fast_software / fast_hardware --> C1[BLAKE3 verify MAC\nover obfuscated tailor]
599-
C1 --> C2[Stream-cipher deobfuscate tailor\nXChaCha20 or AES-256-CTR\nusing OBFS key]
625+
C1 -- fail --> X([ActiveProbeHandler::process\nunidentified packet])
626+
C1 -- ok --> C2[Stream-cipher deobfuscate tailor\nXChaCha20 or AES-256-CTR\nusing OBFS key]
600627
TM -- full_software / full_hardware --> D1[Ephemeral X25519 key-exchange\nwith server static OSK]
601628
D1 --> D2[Anonymous-decrypt tailor\nAEAD with derived key]
629+
D2 -- fail --> X
602630
603631
C2 --> E[Parse Tailor fields\nflags · identity · payload_length · PN]
604-
D2 --> E
605-
E --> F{flags}
632+
D2 -- ok --> E
633+
E --> V{Verify tailor\nauthentication}
634+
V -- fail --> X
635+
V -- ok --> F{flags}
606636
F -- DATA or SHADOWRIDE --> G[Slice ciphertext via payload_length\ndecrypt_payload with session AEAD key]
607637
G --> H([Plaintext delivered to application])
608638
F -- HEALTH_CHECK only --> I([Feed to HealthProvider])
609639
F -- TERMINATION --> J([Session teardown])
640+
F -- DECOY --> K([Silently discard])
610641
```
611642

612643
### Handshake encryption
@@ -813,6 +844,7 @@ In short, these are the main TYPHOON implementation parts:
813844
- Health check provider (one per session): attached to session controller, keeps internal protocol state, manages handshake message timers and injects handshake messages themselves if necessary.
814845
- Flow controller (one per flow): accepts data, prepends a mock header to it and sends it to the flow partner using a UDP socket.
815846
- Decoy provider (one per flow): attached to flow controller, observes (and probably mutates) flow packet stream and injects decoy packets whenever necessary.
847+
- Active probe handler (one per flow): attached to flow controller, receives every packet that could not be identified (tailor authentication failed, or — on the server — packet from an unregistered user), and may send a raw response via the flow socket to mimic another protocol.
816848

817849
#### Identification and rebinding
818850

@@ -1191,6 +1223,11 @@ This design allows each flow manager (or each per-user slot within a server flow
11911223
The construction trait `DecoyCommunicationMode<T, AE>` extends `DecoyProvider` with a single `new()` constructor and is the target of `decoy_factory::<T, AE, DP>()`.
11921224
`random_decoy_factory()` selects randomly among all five built-in providers on every invocation and is the default when no override is supplied.
11931225

1226+
Active probe handlers implement `ActiveProbeHandler<AE>` (generic in the async executor, object-safe because `AE` is fixed at the usage site) and are constructed through a `ProbeFactory<AE>` — a simple no-arg closure `Arc<dyn Fn() -> Box<dyn ActiveProbeHandler<AE>> + Send + Sync>`.
1227+
Unlike decoy providers, which receive their context at construction time, probe handlers receive a `Weak<dyn ProbeFlowSender>` and an `Arc<Settings<AE>>` in a single async `start()` call made after the flow manager's `Arc` is fully initialised (post `Arc::new_cyclic`).
1228+
`ProbeFlowSender` is the raw-send interface exposed to handlers: `ClientFlowManager` implements it by calling `Socket::send` (ignoring the target address, since the socket is already connected), while `ServerFlowManager` calls `Socket::send_to` with the supplied `SocketAddr`.
1229+
`probe_factory::<AE, PH>()` constructs a factory for any `PH: ActiveProbeHandler<AE> + Default + 'static`, and `NoopProbeHandler` (the default when no factory is supplied) is a zero-cost `Default`-derived stub.
1230+
11941231
### Session module
11951232

11961233
The `session` module owns session lifecycle, health checks, and the user-visible data pipe.
@@ -1354,3 +1391,21 @@ Certificates in such a setup could either embed a stable relay address (the list
13541391
**The challenge**: Dynamic plug/unplug of flow managers while sessions are live requires atomic consistency guarantees between the session state (held by the listener) and the per-flow address tables.
13551392
Any message routed to a flow manager that has just gone offline must be either retried on another flow or transparently dropped, while avoiding split-brain scenarios where the listener believes a flow is active but packets are silently lost.
13561393
The synchronization protocol, failure detection timeout, and certificate invalidation strategy all need careful co-design to keep the overall system both correct and efficient.
1394+
1395+
### Periodic server fake header/body mode rotation
1396+
1397+
In the current design, the fake header field layout and fake body mode for each flow are fixed at construction time (encoded in `FlowConfig`) and never change for the lifetime of the flow.
1398+
A long-lived session therefore produces traffic with a statistically consistent structure — the distribution of packet lengths and of fake header field widths remains constant — which may allow a patient observer to fingerprint TYPHOON flows over time even without breaking the cryptography.
1399+
1400+
Because fake headers and fake bodies are purely decorative and do not affect how the receiving side locates or parses the tailor (the tailor position is always derived from known length constants, not from the fake header contents), neither side needs to know or agree on the other side's current configuration.
1401+
Each flow manager can therefore rotate its own `FlowConfig` independently and at its own pace — switching to a freshly sampled layout after a random interval — without any protocol signaling or cross-side coordination.
1402+
1403+
This is analogous to the _protocol polymorphism_ mechanism in [OBFS4](https://gitlab.com/yawning/obfs4/-/blob/master/doc/obfs4-spec.txt), where the server periodically shifts its observable traffic characteristics to frustrate long-term statistical classifiers.
1404+
The key difference is that OBFS4 achieves this by exchanging explicit polymorphism messages on the wire, whereas TYPHOON requires no such messages: since the two sides are fully decoupled, each can resample its `FlowConfig` unilaterally using `FlowConfig::random`, with the change taking effect on the very next outgoing packet.
1405+
1406+
Rotation is opt-in and configured per flow: `FlowConfig` carries an optional rotation interval range — `None` disables rotation entirely, while `Some((min_ms, max_ms))` enables it and bounds the random delay between successive resamples.
1407+
`FlowConfig::random` enables rotation with `TYPHOON_FLOW_ROTATION_PROBABILITY` probability, sampling the interval bounds from `[TYPHOON_FLOW_ROTATION_INTERVAL_MIN, TYPHOON_FLOW_ROTATION_INTERVAL_MAX]`.
1408+
1409+
**The challenge**:
1410+
The rotation interval itself must not become a new fingerprint.
1411+
A fixed period (e.g. every _N_ seconds) would create detectable periodic discontinuities in the packet-length or field-width distributions.

typhoon/src/debug.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,9 @@ pub async fn run_debug(certificate: ClientCertificate, mode: DebugMode, settings
200200
// Build client socket — if this fails the server is unreachable.
201201
// Use empty flow config for all addresses: fake body/header would prepend bytes that the
202202
// server's handshake parser cannot strip, causing a crypto overflow on decapsulation.
203-
let empty_config = FlowConfig::new(FakeBodyMode::Empty, FakeHeaderConfig::new(vec![]));
204203
let mut builder = ClientSocketBuilder::<StaticByteBuffer, DefaultExecutor, DebugClientConnectionHandler>::new(certificate.clone(), DebugClientConnectionHandler).with_settings(settings.clone());
205204
for &addr in certificate.addresses() {
206-
builder = builder.with_flow_config(addr, empty_config.clone());
205+
builder = builder.with_flow_config(addr, FlowConfig::new(FakeBodyMode::Empty, FakeHeaderConfig::new(vec![])));
207206
}
208207
let socket = match builder.build().await {
209208
Ok(s) => s,

typhoon/src/defaults.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
//! Default concrete types and re-exports for the most common TYPHOON configurations.
22
//!
33
//! Provides [`DefaultExecutor`] (backed by the active runtime feature flag), the
4-
//! [`DefaultServerConnectionHandler`] / [`DefaultClientConnectionHandler`] pair, and
5-
//! re-exports [`DecoyFactory`], [`decoy_factory`], and [`random_decoy_factory`] so callers
6-
//! do not need to import from the deeper `flow::decoy` path.
4+
//! [`DefaultServerConnectionHandler`] / [`DefaultClientConnectionHandler`] pair,
5+
//! [`NoopProbeHandler`] (the default no-op active-probing handler), and re-exports
6+
//! [`DecoyFactory`], [`decoy_factory`], and [`random_decoy_factory`] so callers do not need to
7+
//! import from the deeper `flow::decoy` path.
78
89
use std::future::Future;
10+
use std::net::SocketAddr;
911
use std::str::from_utf8;
10-
#[cfg(feature = "async-std")]
11-
use std::sync::Arc;
12+
use std::sync::{Arc, Weak};
1213

14+
use async_trait::async_trait;
1315
use cfg_if::cfg_if;
1416
use log::{debug, warn};
1517
#[cfg(feature = "tokio")]
1618
use tokio::spawn;
1719

18-
use crate::bytes::{ByteBuffer, StaticByteBuffer};
20+
use crate::bytes::{ByteBuffer, DynamicByteBuffer, StaticByteBuffer};
1921
pub use crate::flow::decoy::{DecoyFactory, decoy_factory, random_decoy_factory};
22+
pub use crate::flow::probe::{ActiveProbeHandler, ProbeFactory, ProbeFlowSender, probe_factory};
2023
use crate::settings::Settings;
2124
use crate::settings::consts::DEFAULT_TYPHOON_ID_LENGTH;
2225
pub use crate::tailor::{ClientConnectionHandler, ServerConnectionHandler};
@@ -144,6 +147,17 @@ impl ServerConnectionHandler<StaticByteBuffer> for DefaultServerConnectionHandle
144147
}
145148
}
146149

150+
/// No-op active probe handler. Both [`start`] and [`process`] do nothing;
151+
/// unidentified packets are dropped silently.
152+
#[derive(Default)]
153+
pub struct NoopProbeHandler;
154+
155+
#[async_trait]
156+
impl<AE: AsyncExecutor + 'static> ActiveProbeHandler<AE> for NoopProbeHandler {
157+
async fn start(&mut self, _: Weak<dyn ProbeFlowSender>, _: Arc<Settings<AE>>) {}
158+
async fn process(&mut self, _: DynamicByteBuffer, _: Option<SocketAddr>) {}
159+
}
160+
147161
/// Client connection handler with no custom initial data that encodes `CARGO_PKG_VERSION`
148162
/// into the handshake tailor ID field.
149163
pub struct DefaultClientConnectionHandler;

typhoon/src/flow/client.rs

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
/// Client-side flow manager implementation.
2+
use std::future::Future;
3+
use std::net::SocketAddr;
4+
use std::pin::Pin;
25
use std::sync::{Arc, Weak};
36

47
use crate::bytes::DynamicByteBuffer;
58
use crate::cache::CachedValue;
69
use crate::crypto::ClientCryptoTool;
7-
use crate::flow::common::{FlowManager, FlowReceiveInternal, FlowSendInternal};
10+
use crate::defaults::NoopProbeHandler;
11+
use crate::flow::common::{FlowManager, FlowReceiveInternal, FlowSendInternal, ProcessIncomingResult};
812
use crate::flow::config::FlowConfig;
913
use crate::flow::decoy::{DecoyFactory, DecoyFlowSender, DecoyProvider};
1014
use crate::flow::error::FlowControllerError;
15+
use crate::flow::probe::{ActiveProbeHandler, ProbeFactory, ProbeFlowSender};
1116
use crate::settings::Settings;
1217
use crate::tailor::IdentityType;
13-
use crate::utils::socket::Socket;
18+
use crate::utils::socket::{Socket, SocketError};
1419
use crate::utils::sync::{AsyncExecutor, Mutex};
1520

1621
/// Client-side flow manager that handles packet encryption, decoy traffic, and socket I/O.
@@ -21,18 +26,27 @@ pub struct ClientFlowManager<T: IdentityType + Clone, AE: AsyncExecutor> {
2126
sock: Socket,
2227
mtu: usize,
2328
settings: Arc<Settings<AE>>,
29+
/// Handler for unidentified packets. Locked only for rare unexpected arrivals.
30+
probe_handler: Mutex<Box<dyn ActiveProbeHandler<AE>>>,
2431
}
2532

2633
impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> ClientFlowManager<T, AE> {
2734
/// Create a new client flow manager.
28-
pub(crate) async fn new(config: FlowConfig, mut cipher: CachedValue<ClientCryptoTool<T>>, settings: Arc<Settings<AE>>, sock: Socket, factory: &DecoyFactory<T, AE>) -> Result<Arc<Self>, FlowControllerError> {
35+
pub(crate) async fn new(config: FlowConfig, probe_factory: Option<&ProbeFactory<AE>>, mut cipher: CachedValue<ClientCryptoTool<T>>, settings: Arc<Settings<AE>>, sock: Socket, factory: &DecoyFactory<T, AE>) -> Result<Arc<Self>, FlowControllerError> {
2936
let identity = cipher.get_mut().map_err(FlowControllerError::MissingCache)?.identity();
3037
let send_provider = cipher.create_sibling().map_err(FlowControllerError::MissingCache)?;
3138
let receive_provider = cipher.create_sibling().map_err(FlowControllerError::MissingCache)?;
39+
let handler_factory = probe_factory.cloned();
40+
let settings_for_start = Arc::clone(&settings);
3241

3342
let manager_ref = Arc::new_cyclic(|m: &Weak<ClientFlowManager<T, AE>>| {
3443
let mgr: Weak<dyn DecoyFlowSender> = m.clone();
3544
let decoy = factory(mgr, settings.clone(), identity);
45+
let probe_handler: Box<dyn ActiveProbeHandler<AE>> = match &handler_factory {
46+
Some(f) => f(),
47+
None => Box::new(NoopProbeHandler),
48+
};
49+
let mtu = settings.mtu();
3650
ClientFlowManager {
3751
decoy_provider: Mutex::new(decoy),
3852
send_internal: Mutex::new(FlowSendInternal {
@@ -43,15 +57,24 @@ impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> ClientFlowM
4357
provider: receive_provider,
4458
}),
4559
sock,
46-
mtu: settings.mtu(),
60+
mtu,
4761
settings,
62+
probe_handler: Mutex::new(probe_handler),
4863
}
4964
});
5065
manager_ref.decoy_provider.lock().await.start().await;
66+
let weak: Weak<dyn ProbeFlowSender> = Arc::downgrade(&manager_ref) as Weak<dyn ProbeFlowSender>;
67+
manager_ref.probe_handler.lock().await.start(weak, settings_for_start).await;
5168
Ok(manager_ref)
5269
}
5370
}
5471

72+
impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> ProbeFlowSender for ClientFlowManager<T, AE> {
73+
fn send_raw<'a>(&'a self, packet: DynamicByteBuffer, _target: SocketAddr) -> Pin<Box<dyn Future<Output = Result<(), SocketError>> + Send + 'a>> {
74+
Box::pin(async move { self.sock.send(packet).await.map(|_| ()) })
75+
}
76+
}
77+
5578
impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> FlowManager for ClientFlowManager<T, AE> {
5679
async fn send_packet(&self, packet: DynamicByteBuffer, generated: bool) -> Result<(), FlowControllerError> {
5780
let notified_packet = {
@@ -78,11 +101,16 @@ impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> FlowManager
78101
if notified_packet.is_none() {
79102
continue;
80103
}
81-
notified_packet
104+
notified_packet.unwrap()
82105
};
83106

84-
let mut lock = self.receive_internal.lock().await;
85-
if let Some(result) = lock.process_incoming(notified_packet.unwrap(), self.settings.pool())? {
107+
let incoming_packet = {
108+
let mut lock = self.receive_internal.lock().await;
109+
lock.process_incoming(notified_packet, self.settings.pool())?
110+
};
111+
if let ProcessIncomingResult::Unexpected(pkt) = incoming_packet {
112+
self.probe_handler.lock().await.process(pkt, None).await;
113+
} else if let ProcessIncomingResult::Valid(result) = incoming_packet {
86114
return Ok(result);
87115
}
88116
}

0 commit comments

Comments
 (0)