Skip to content

Commit aee20c4

Browse files
author
pseusys
committed
initial
1 parent 89d0a64 commit aee20c4

10 files changed

Lines changed: 325 additions & 57 deletions

File tree

PROTOCOL.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ flowchart LR
3030
CS --> CFM2["FlowManager → addr 2"]
3131
CSM --> CHCP["HealthCheckProvider"]
3232
CFM1 --> CDP1["DecoyProvider"]
33+
CFM1 --> CAPH1["ActiveProbeHandler"]
3334
CFM2 --> CDP2["DecoyProvider"]
35+
CFM2 --> CAPH2["ActiveProbeHandler"]
3436
end
3537
3638
subgraph Server
@@ -39,7 +41,9 @@ flowchart LR
3941
L --> SFM1["FlowManager :port 1"]
4042
L --> SFM2["FlowManager :port 2"]
4143
SFM1 --> SDP1["DecoyProvider (per user)"]
44+
SFM1 --> SAPH1["ActiveProbeHandler"]
4245
SFM2 --> SDP2["DecoyProvider (per user)"]
46+
SFM2 --> SAPH2["ActiveProbeHandler"]
4347
L --> SSM1["SessionManager (User A)"]
4448
L --> SSM2["SessionManager (User B)"]
4549
SSM1 --> SHCP1["HealthCheckProvider"]
@@ -125,6 +129,7 @@ The tailor is the only part of the message that should be decrypted by the flow
125129
It always starts at `packet_length - tailor_length - tailor_encryption_overhead` and ends at the end of the packet.
126130
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.
127131
If the decoy flag is set, the packet should be discarded right away by the flow manager.
132+
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.
128133

129134
### Fake body
130135

@@ -193,10 +198,14 @@ sequenceDiagram
193198
194199
FM-->>FM: receive UDP datagram
195200
FM->>FM: Strip FakeHeader + FakeBody
196-
FM->>FM: Decrypt tailor → verify identity + PN
197-
FM-->>SM: deliver(body)
198-
SM->>SM: Decrypt body (session key)
199-
SM-->>App: recv() → plaintext payload
201+
alt tailor decryption / authentication ok
202+
FM->>FM: Decrypt tailor → verify identity + PN
203+
FM-->>SM: deliver(body)
204+
SM->>SM: Decrypt body (session key)
205+
SM-->>App: recv() → plaintext payload
206+
else tailor decryption / authentication fail
207+
FM->>FM: ActiveProbeHandler::process(raw packet)
208+
end
200209
```
201210

202211
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.
@@ -459,6 +468,23 @@ The subheader mode can have these values:
459468

460469
By default, subheader mode is chosen with equal probability for every option.
461470

471+
### Active probing protection
472+
473+
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.
474+
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.
475+
476+
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:
477+
478+
- **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.
479+
- **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.
480+
481+
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).
482+
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.
483+
484+
The default implementation (`NoopProbeHandler`) drops all unidentified packets silently.
485+
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.
486+
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`.
487+
462488
## Cryptography
463489

464490
The following requirements are taken into account for TYPHOON protocol cryptography suite selection:
@@ -559,17 +585,22 @@ flowchart TD
559585
A([UDP socket · recv]) --> B[Flow layer: strip FakeHeader ‖ FakeBody]
560586
B --> TM{Feature mode}
561587
TM -- fast_software / fast_hardware --> C1[BLAKE3 verify MAC\nover obfuscated tailor]
562-
C1 --> C2[Stream-cipher deobfuscate tailor\nXChaCha20 or AES-256-CTR\nusing OBFS key]
588+
C1 -- fail --> X([ActiveProbeHandler::process\nunidentified packet])
589+
C1 -- ok --> C2[Stream-cipher deobfuscate tailor\nXChaCha20 or AES-256-CTR\nusing OBFS key]
563590
TM -- full_software / full_hardware --> D1[Ephemeral X25519 key-exchange\nwith server static OSK]
564591
D1 --> D2[Anonymous-decrypt tailor\nAEAD with derived key]
592+
D2 -- fail --> X
565593
566594
C2 --> E[Parse Tailor fields\nflags · identity · payload_length · PN]
567-
D2 --> E
568-
E --> F{flags}
595+
D2 -- ok --> E
596+
E --> V{Verify tailor\nauthentication}
597+
V -- fail --> X
598+
V -- ok --> F{flags}
569599
F -- DATA or SHADOWRIDE --> G[Slice ciphertext via payload_length\ndecrypt_payload with session AEAD key]
570600
G --> H([Plaintext delivered to application])
571601
F -- HEALTH_CHECK only --> I([Feed to HealthProvider])
572602
F -- TERMINATION --> J([Session teardown])
603+
F -- DECOY --> K([Silently discard])
573604
```
574605

575606
### Handshake encryption
@@ -776,6 +807,7 @@ In short, these are the main TYPHOON implementation parts:
776807
- 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.
777808
- Flow controller (one per flow): accepts data, prepends a mock header to it and sends it to the flow partner using a UDP socket.
778809
- Decoy provider (one per flow): attached to flow controller, observes (and probably mutates) flow packet stream and injects decoy packets whenever necessary.
810+
- 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.
779811

780812
#### Identification and rebinding
781813

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

1188+
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>`.
1189+
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`).
1190+
`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`.
1191+
`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.
1192+
11561193
### Session module
11571194

11581195
The `session` module owns session lifecycle, health checks, and the user-visible data pipe.
@@ -1316,3 +1353,21 @@ Certificates in such a setup could either embed a stable relay address (the list
13161353
**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.
13171354
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.
13181355
The synchronization protocol, failure detection timeout, and certificate invalidation strategy all need careful co-design to keep the overall system both correct and efficient.
1356+
1357+
### Periodic server fake header/body mode rotation
1358+
1359+
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.
1360+
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.
1361+
1362+
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.
1363+
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.
1364+
1365+
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.
1366+
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.
1367+
1368+
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.
1369+
`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]`.
1370+
1371+
**The challenge**:
1372+
The rotation interval itself must not become a new fingerprint.
1373+
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: 42 additions & 9 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,28 @@ 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>(
74+
&'a self,
75+
packet: DynamicByteBuffer,
76+
_target: SocketAddr,
77+
) -> Pin<Box<dyn Future<Output = Result<(), SocketError>> + Send + 'a>> {
78+
Box::pin(async move { self.sock.send(packet).await.map(|_| ()) })
79+
}
80+
}
81+
5582
impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> FlowManager for ClientFlowManager<T, AE> {
5683
async fn send_packet(&self, packet: DynamicByteBuffer, generated: bool) -> Result<(), FlowControllerError> {
5784
let notified_packet = {
@@ -78,13 +105,19 @@ impl<T: IdentityType + Clone + 'static, AE: AsyncExecutor + 'static> FlowManager
78105
if notified_packet.is_none() {
79106
continue;
80107
}
81-
notified_packet
108+
notified_packet.unwrap()
82109
};
83110

84-
let mut lock = self.receive_internal.lock().await;
85-
match lock.process_incoming(notified_packet.unwrap(), self.settings.pool())? {
86-
Some(result) => return Ok(result),
87-
None => continue,
111+
{
112+
let mut lock = self.receive_internal.lock().await;
113+
match lock.process_incoming(notified_packet, self.settings.pool())? {
114+
ProcessIncomingResult::Valid(result) => return Ok(result),
115+
ProcessIncomingResult::Decoy => continue,
116+
ProcessIncomingResult::Unexpected(pkt) => {
117+
drop(lock);
118+
self.probe_handler.lock().await.process(pkt, None).await;
119+
}
120+
}
88121
}
89122
}
90123
}

0 commit comments

Comments
 (0)