peasub 0.2.0

A metadata-private gossip protocol built on peashape, with constant-rate or Poisson-distributed cover traffic.
Documentation

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 pea2pea Handshake).

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:

  1. The high-priority lane — messages submitted via Node::publish. Always drained first, so user data is never delayed by relay traffic. Bounded; publish returns Error::AppOutboxFull if it saturates.
  2. 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 std::time::Duration;
use peasub::{CoverStrategy, Node, NodeConfig, ID_SIZE};
use chacha20poly1305::{aead::{Aead, AeadCore, KeyInit, OsRng}, ChaCha20Poly1305, Key, Nonce};

// 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.
fn seal(cipher: &ChaCha20Poly1305, data: &[u8]) -> Vec<u8> {
    assert!(data.len() <= MAX_DATA, "payload exceeds {MAX_DATA} bytes");
    let mut inner = vec![0u8; INNER];          // zero pad is hidden by encryption
    inner[..LEN_HDR].copy_from_slice(&(data.len() as u16).to_be_bytes());
    inner[LEN_HDR..LEN_HDR + data.len()].copy_from_slice(data);

    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
    let ct = cipher.encrypt(&nonce, inner.as_ref()).expect("encrypt");
    let mut out = Vec::with_capacity(PAYLOAD); // 12 + 196 + 16 == 224 == PAYLOAD
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&ct);
    out
}

// Returns the plaintext if the frame authenticates, or None for cover frames
// (and frames addressed to a different key) — decryption failure is the filter.
fn open(cipher: &ChaCha20Poly1305, frame: &[u8]) -> Option<Vec<u8>> {
    let payload = frame.get(ID_SIZE..)?;       // strip peasub's 32-byte ID
    let nonce = Nonce::from_slice(payload.get(..NONCE)?);
    let inner = cipher.decrypt(nonce, payload.get(NONCE..)?).ok()?;
    let len = u16::from_be_bytes(inner.get(..LEN_HDR)?.try_into().ok()?) as usize;
    inner.get(LEN_HDR..LEN_HDR + len).map(<[u8]>::to_vec)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // DEMO ONLY: a hard-coded shared key. A real deployment does key
    // agreement (per-group keys, or a pea2pea Noise handshake) — peasub
    // deliberately stays out of the crypto business.
    let cipher = ChaCha20Poly1305::new(Key::from_slice(&[0x42; 32]));

    let mk = || Node::new(NodeConfig {
        listener_addr: Some("127.0.0.1:0".parse()?),
        cover: CoverStrategy::Constant { interval: Duration::from_millis(100) },
        ..Default::default()
    });

    let alice = mk();
    let bob = mk();
    alice.spawn().await?;
    bob.spawn().await?;

    let bob_addr = bob.local_addr().await?;
    alice.connect(bob_addr).await?;

    let mut bob_rx = bob.subscribe();
    alice.publish(&seal(&cipher, b"hello, peasub"))?;

    // Bob drains frames until one authenticates; cover frames fail to
    // decrypt and are skipped.
    loop {
        if let Ok(frame) = bob_rx.recv().await {
            if let Some(payload) = open(&cipher, &frame) {
                println!("Bob got: {:?}", String::from_utf8_lossy(payload));
                break;
            }
        }
    }

    alice.shutdown().await;
    bob.shutdown().await;
    Ok(())
}

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, use encrypted for 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

pub enum CoverStrategy {
    /// One message per fixed `interval`.
    Constant { interval: Duration },
    /// Inter-arrival times drawn from Exp(rate) - Poisson process.
    Poisson { rate: f64 },
}

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 pea2pea Handshake (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 a pea2pea Handshake that sets TCP_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.