BibaVPN, adaptive padding selects a target of roughly 900–1400 bytes for the first seven inner frames. Set max_pad to 64, send a 100-byte payload, and the resulting frame is 169 bytes. The target was 1,200; the padding budget ran out at 64.
Here is where those numbers come from.
BibaVPN is my experimental SOCKS5/HTTP CONNECT tunnel, written in Rust, with a TLS + WebSocket transport to a server on your own VPS. Its inner frame format reserves five bytes for the header:
Byte offset 0 1 2 3 4 5 ...
+-------+-----------------------+-------+------------------+
| ver | payload length (u24) | pad N | padding | payload|
+-------+-----------------------+-------+------------------+
1 B 3 B 1 B N B L B
Total inner frame size = 5 + N + L
The padding length is a u8, so this layer can add at most 255 bytes. The configured max_pad can reduce that further.
Calculated example, before encryption and outer transport overhead. The upper bar shows the actual frame; the lower bar compares it with the selected target.
The three modes share that byte budget:
| Mode | How it chooses padding |
|---|---|
random |
Uniform random amount from zero through max_pad. |
http-buckets |
Select a size bucket, vary its target by about ±5%, then clamp the required padding. |
adaptive |
Select larger targets for the first seven frames, then targets around 128–512 bytes; clamp the required padding. |
The clamp in frame.rs is:
let need = target_total
.saturating_sub(base)
.min(usize::from(max_pad));
base includes the header. If the payload already exceeds the selected target, saturating_sub returns zero. There is no fragmentation or shrinking in this calculation.
For a fixed target of 1,200 bytes and max_pad = 64, the arithmetic looks like this:
| Payload | With header | Padding added | Resulting inner frame |
|---|---|---|---|
| 100 B | 105 B | 64 B | 169 B |
| 1,150 B | 1,155 B | 45 B | 1,200 B |
| 1,500 B | 1,505 B | 0 B | 1,505 B |
The names of the modes describe the size-selection heuristic. Whether the resulting traffic resembles HTTP requires a capture and a comparison. The inner frame still passes through encryption, WebSocket framing, TLS records, and TCP segmentation before it appears on the wire.
For the PSK data path, the boundaries look like this:
inner payload
|
v
padded inner frame <-- sizes calculated above
|
v
PSK encryption wrapper
|
v
WebSocket / TLS / TCP <-- network capture observes this traffic
A capture without TLS keys does not expose the WebSocket payload. It does expose traffic direction, timing, volume, and encrypted record lengths. Those are the properties the shaping settings aim to influence.
The timing controls have their own limits. In the current multiplexer writer, the artificial-delay branch excludes DATA and window updates:
if command.flags != MUX_FLAG_DATA && command.flags != MUX_FLAG_WIN {
// Apply the configured timing controls here.
}
Delaying every DATA fragment would accumulate quickly. Five thousand serialized waits of 20 ms would add 100 seconds of waiting. That is an illustrative calculation; it is not a measured transfer time. Delaying window updates would also hold back a sender that is waiting for permission to transmit more bytes.
Dummy frames are another setting. They create traffic without application data, which consumes bandwidth even during otherwise quiet periods. Their cadence matters as much as their size: periodic dummy traffic can introduce a pattern of its own.
The code gives us the frame-size limits and the points where delays are applied. Establishing whether a setting helps against a particular classifier still requires network measurements. These examples explain the implementation; they do not establish a DPI-bypass result.
Source code and protocol documentation: BibaVPN on GitHub.

Top comments (0)