peasub
A metadata-private gossip protocol built on top of peashape,
which in turn is built on pea2pea. It disseminates messages
through a peer-to-peer network while resisting traffic analysis
via constant-rate or Poisson-distributed cover traffic.
peasub is one specific application of peashape's general
traffic-shaping primitive. If you want to build a different
private protocol (private RPC, private file chunks, private
membership, …) you can use peashape directly.
Threat model
peasub is designed to defeat a passive global network
observer who can:
- observe every byte sent between every pair of nodes;
- observe the timing of every byte;
- but cannot break the cryptographic primitives protecting
the link (e.g. TLS via a
pea2peaHandshake).
Against such an observer, the cover-traffic schedule ensures that the timing distribution and size distribution of a node's outbound traffic are independent of whether the application is publishing messages or not. The observer learns nothing about the existence, frequency, or destination of user activity beyond the rate the node has been configured for.
peasub does not attempt to defeat an observer that can
compromise the node itself, that can correlate
application-level events with coarse traffic features, or that
controls a non-trivial fraction of the network's nodes.
How it works
Every gossip frame on the wire is a fixed-size, length-prefixed buffer. The first 32 bytes are a random message identifier; the remainder is the application payload padded with random bytes. Real (application-submitted) messages and cover (dummy) messages are indistinguishable on the wire as long as the payload bytes are themselves indistinguishable from random — i.e. encrypted. A plaintext payload (anything with recognizable structure) defeats the cover property; see Receiving messages for the recommended pattern.
peashape runs a background task that ticks at the configured
cover rate. On every tick, it pulls the next message to send
from one of two priority lanes:
- The high-priority lane — messages submitted via
Node::publish. Always drained first, so user data is never delayed by relay traffic. Bounded;publishreturnsError::AppOutboxFullif it saturates. - The low-priority lane — messages received from peers for re-broadcast. A drop-oldest LIFO: freshly received frames are placed at the front, so the next cover tick forwards them immediately. Under sustained inflow, the oldest queued relay is discarded first.
If both lanes are empty, a fresh cover message is generated. All three paths produce a frame of the same on-the-wire size, so an observer cannot tell them apart by length or by when they arrive.
The message is then sent to fanout distinct randomly-chosen
peers (default 3). Because every tick emits exactly fanout
frames of the same size — real or cover — the outbound
traffic's timing and size distributions remain independent of
application activity.
A peer receiving a frame deduplicates it by ID (LRU cache),
delivers it to local subscribers via a
tokio::sync::broadcast channel, and re-queues it for
forwarding into the low-priority lane.
Receiving messages
Node::subscribe yields every novel frame the node sees on
the wire — real and cover alike. This is intentional: the
library cannot tell them apart (that's the privacy property),
so the responsibility for distinguishing real application data
from random cover bytes falls on the application.
The recommended pattern is authenticated encryption over the payload. This does double duty:
- the AEAD tag is the "recognizable structure" — a frame that fails to authenticate is cover (or addressed to another key), so you need no plaintext magic header, and there is no plaintext tell on the wire;
- ciphertext is indistinguishable from the random cover bytes, which is what makes the metadata-privacy claim hold against an observer that can read raw frames;
- relaying nodes forward frames they cannot decrypt, so content is hidden from intermediate peers too.
Put the true length inside the encrypted block and size the
sealed payload to exactly message_size - ID_SIZE so peasub
adds no padding and the boundaries are deterministic. The Quick
start below is a complete worked example; cargo run --example encrypted runs it end to end.
Quick start
Two nodes, one real message, end to end. Alice publishes, Bob receives and extracts the payload from the cover stream:
use Duration;
use ;
use ;
// With the 256-byte default frame, the app owns message_size - ID_SIZE = 224
// bytes. Minus a 12-byte nonce and 16-byte Poly1305 tag, that leaves a
// fixed 196-byte encrypted block (2 bytes length + up to 194 bytes data).
const PAYLOAD: usize = 256 - ID_SIZE; // 224
const NONCE: usize = 12;
const TAG: usize = 16;
const INNER: usize = PAYLOAD - NONCE - TAG; // 196
const LEN_HDR: usize = 2;
const MAX_DATA: usize = INNER - LEN_HDR; // 194
// Encrypt `data` into a full-width frame payload. The contents peasub puts
// on the wire are ciphertext, so a real frame is byte-for-byte
// indistinguishable from a (random) cover frame.
// Returns the plaintext if the frame authenticates, or None for cover frames
// (and frames addressed to a different key) — decryption failure is the filter.
async
The crate ships four runnable examples:
cargo run --example encrypted— the AEAD pattern above, end to end.cargo run --example two_nodes— the minimal subscribe/filter mechanics (with a plaintext marker — see the note in the file; not privacy-preserving, useencryptedfor that).cargo run --example demo— visualizes the traffic-analysis resistance: constant rate, one frame size, no signal at publish time.cargo run --example poisson_demo— the same, with a Poisson schedule.
Choosing the cover rate
The cover rate is the only knob that controls the privacy / bandwidth trade-off:
- Higher cover rate → tighter privacy, more bandwidth.
- Lower cover rate → looser privacy, less bandwidth.
The application must publish no faster than the cover rate;
otherwise its messages accumulate in the application outbox.
The relay_outbox_capacity should be sized to
fanout * cover_rate * drain_seconds to keep relay backlog
from crowding out user data.
Fanout
fanout (default 3) controls how many distinct peers each
outbound frame is forwarded to on every cover tick. It trades
convergence speed against bandwidth:
- Higher fanout → faster gossip convergence (
O(log N)hops), proportionally more bandwidth. - Lower fanout → slower convergence (
O(N)hops at fanout 1), less bandwidth.
Total outbound bandwidth is
fanout * message_size * cover_rate. Raising fanout does
not change the timing distribution of outbound traffic
(every tick still emits exactly fanout frames, real or
cover), so the metadata-privacy property is preserved
regardless of the fanout value.
The effective fanout is clamped to the number of connected
peers on every tick, so a node with fewer peers than fanout
simply sends to all of them.
Cover strategies
Constant is the simplest and most predictable. Poisson
makes the inter-arrival times themselves random, so an
observer who can only see the node's traffic cannot
statistically distinguish the Poisson-scheduled cover stream
from one whose timing is influenced by user activity — the
strictest form of metadata privacy.
Limitations
- No application-level encryption. Cover hides the timing
of messages, not their content. The application is
responsible for encrypting payloads if end-to-end
confidentiality is needed; a
pea2peaHandshake(e.g. Noise) can be layered on top for transport confidentiality. - TCP only. Built on
pea2pea, which is TCP-based. A timing adversary that observes TCP-level behavior (Nagle, delayed ACKs, etc.) may be able to extract additional features; consider disabling Nagle via apea2peaHandshakethat setsTCP_NODELAY, or layering constant-rate shaping at the application layer if that is in scope.
Building on top of peasub
peasub's Node exposes the underlying peashape::Node via
node.peashape(), so applications that want more granular
control over the shaping layer (e.g. a different
ShapingScope or a custom Lane) can do so by calling
peashape::Node methods directly.
License
Dual-licensed under MIT or CC0-1.0, at your option.