The BIP-324 spec has two optional features designed to help hide detection of bitcoin p2p traffic: garbage bytes and decoy packets. But why add this complexity to the spec if the data is encrypted anyways? Well, one of the goals of BIP-324 is censorship resistance. And while encrypting the data is definitely required for this, it might still be possible for a third party to guess that communication is bitcoin related. Traffic analysis can be done on the size and timing of data sent between parties. And as it turns out, bitcoin has some pretty easy to see patterns.
Compared to communication like client/server webapps or streaming, bitcoin traffic is fairly bidirectional. The nodes are often sending equal amounts of data to each other. Connections are also relatively long-lived. There are fairly constant ping
and pongs
being sent. But perhaps the most damning is the extremely consistent burst of data every ten minutes as a new block propagates across the network.
def looks_like_bitcoin(connection_stats):
# Long-lived?
if connection_stats.duration > 30_minutes:
score += 1
# Bidirectional?
ratio = connection_stats.bytes_sent / connection_stats.bytes_received
if 0.5 < ratio < 2.0:
score += 1
# Regular spikes?
if has_periodic_spikes(connection_stats, period=~10_minutes):
score += 1
# Common packet sizes?
if has_packet_size_clusters([32, 61, 97, ...]):
score += 1
return score > threshold
A simple script to detect bitcoin usage with no introspection on the data itself.
So back to garbage and decoys. Why two features for one job? The garbage bytes are sent at the beginning of a channel’s handshake before the communication is encrypted. They are to help hide the handshake itself which is often one of the most detectable parts of a protocol. For example in BIP-324 itself, the initiator and the responder both have to send a 64 byte public key before anything else can happen in order to create the secret materials. Now this isn’t as bad as some protocols which have plaintext headers of static data (e.g. wireguard) which can be blocked with simple iptables
rules. But it is still detectable. This is where the garbage comes in. The 64 bit keys are ElligatorSwift encoded, so look totally random themselves even though they are not encrypted. BIP-324 then calls for up to 4KiB of garbage bytes to be sent, so the shape of the first steps of the handshake is now hidden.
After the public keys have been exchanged the communication can go dark. And this is where decoy packets can take over to help keep the shape of the traffic hidden. The strategy for sending garbage bytes is pretty straightforward, generate some random data between 0 and 4095 bytes long and send it. The timing occurs at the same time as caller action. As in, the garbage is sent right when the caller asks to initiate a handshake or respond to one. I don’t think the strategy for decoys can be quite so simple.
If decoys are generated only at the same time as normal activity, is that good enough to hide bitcoin usage patterns? Decoys could be placed on either side of a genuine packet, but practically speaking, they would still all be sent around the same time as the genuine packet. No program is going to be stoked on a 5 minute block by a simple “send data” call. So the shape of a single packet can be masked, but the timing correlations will still be there. So the easy solution is most likely not good enough.
Here are some harder ones.
- Generate decoys constantly.
- Delay real messages.
- Coordinate with peers.
Let’s start from the bottom. Coordinating with peers adds a large cost, essentially an extension to the BIP-324 protocol, but the benefit is that parties could now work together to make the channel look almost like some other type of data (e.g. client/server). But coordination is tough, how far can we get without it?
Delaying real messages requires no coordination, but in the bitcoin context it might be completely unacceptable. Blocks and transactions need to propagate fast. While this would help a ton with timing analysis, the cost is probably too high.
That leaves us with generating constant decoys. This complicates the user interface at some point since a new process is probably required to fire off this dummy data without caller initiation. It also adds bandwidth requirements. But it might buy quite a bit in noise relative to those costs. It will probably still be possible to detect bitcoin usage, like maybe the block propagation isn’t quite hidden, but it would be more expensive. Perhaps a simple python script is no longer good enough.
The interface does get tricky now. And I am not quite sure on the requirements yet. Should the traffic-shaper be aware of the data flowing through the channel? Or should it just be given write access and fire away willy-nilly? Feels like awareness would be much more powerful.
pub enum TrafficStrategy {
/// Just constant decoys, no awareness needed.
Constant { rate: BytesPerSecond },
/// Adaptive based on real traffic, needs awareness.
Adaptive {
min_rate: BytesPerSecond,
max_rate: BytesPerSecond,
burst_smoothing: bool,
},
/// Pattern matching, needs awareness.
PatternMasking {
target_pattern: TrafficPattern,
max_delay: Duration,
},
}
Possible shaping patterns.
OK, so let’s assume the TrafficShaperProtocol
wraps a Protocol
. I think an implementation question then arises for how to share the writer half of the connection. Genuine packets should flow through immediately. If some interior mutability pattern is used, Arc<Mutex<W>>
, some large decoy writes could hold the lock and box out a genuine. It might make sense for some internal channels with message passing be used in order to ensure genuine packet priority, even though more complex out of the gate.