Skip to content

feat(3b): junk/decoy packets + traffic-shaping — blur the flow-shape fingerprint - #47

Merged
vxfemboy merged 12 commits into
mainfrom
feat/antidpi-3b
Jul 9, 2026
Merged

feat(3b): junk/decoy packets + traffic-shaping — blur the flow-shape fingerprint#47
vxfemboy merged 12 commits into
mainfrom
feat/antidpi-3b

Conversation

@vxfemboy

@vxfemboy vxfemboy commented Jul 9, 2026

Copy link
Copy Markdown
Member

Sub-project #3, milestone 3b: junk/decoy packets + traffic-shaping

Blurs yip's residual flow-shape fingerprint — the statistical silhouette 3a leaves (a 2-packet handshake opener + a constant 30 ms Control pulse) — with latency-free junk/decoy traffic, all behind the existing obf_psk and reusing the 3a yip-obf envelope (no new crate, no wire-format change). Builds on merged 3a.

Spec: docs/superpowers/specs/2026-07-08-antidpi-junk-traffic-shaping-3b-design.md · Plan: docs/superpowers/plans/2026-07-08-antidpi-junk-traffic-shaping-3b.md

What it does

A new JUNK_TYPE=6 ptype: junk is plaintext [JUNK_TYPE][random body] that obf_egress wraps once (so it's byte-indistinguishable from real traffic to anyone without obf_psk); the receiver silently drops it (inert — no reply, zero reflection/amplification). Three latency-free techniques:

  1. Handshake junk burst (automatic with obf_psk) — Jc ∈ 3..=12 decoys around the direct-path HandshakeInit, so the flow no longer opens with a countable "2 packets then data."
  2. Idle cover traffic (opt-in cover_traffic_ms, default off) — one session-keyed decoy per interval to idle direct peers, so the flow never goes tellingly silent.
  3. Control-cadence jitter (automatic) — jitters the 30 ms loss-feedback timer's emission ±25% (→ 23–37 ms), smearing the ~33 Hz pulse 3a left constant.

A tiny handwritten xorshift PRNG (seeded once from getrandom) fills junk bodies/lengths/counts; getrandom stays only for the per-packet nonce. The junk body is keystream-masked, so its content is irrelevant to indistinguishability.

Invariants (opus whole-branch review: READY TO MERGE)

  1. obf_psk absent ⇒ byte-identical to merged 3a/2a/2b/2c (every path gated on obf_key.is_some()).
  2. Junk is a layer over Noise/AEAD — a keystream-XOR envelope over a throwaway body; touches no session/admission/routing state.
  3. Silent-drop is inertJUNK_TYPE ⇒ drop; no reply, no state change, no panic from any junk/garbage datagram.
  4. Single-wrap, bounded under MTU (≤1099 < 1200); latency-free (junk off-hot-path / idle-only / reporting-timer-only) — the data hot path is never delayed.

Testing

  • 134 unit tests (parallel-clean).
  • netns: 6 obf-on (direct/relay/punch/discovery-obf now exercise the junk burst + jitter automatically; a new obfuscated_ping_with_cover) + 10 obf-off (byte-identical no-regression) × both drivers (poll + io_uring), all green.
  • nDPI oracle no-regression — with junk now in the capture, the flow still classifies as Unknown with no NDPI_OBFUSCATED_TRAFFIC.
  • New lightweight flow-shape structural check — over 8 independent sessions the handshake-opener packet count is > 4 (junk present, above the two-sided-glare baseline) and takes multiple distinct values (Jc randomizes the opener). Deterministic, honestly framed as "no obviously-constant shape," not "provably unclassifiable."

Scope / non-goals (later milestones)

Data-path timing shaping / inter-packet delay (would add latency) → deferred opt-in "paranoid mode" / sub-project #4 · entropy shaping (SUSPICIOUS_ENTROPY, still report-only in the oracle) → 3c TLS/QUIC mimicry · port plausibility → 3d. Relay-path junk is out of scope (handshake junk is direct-only; cover skips relay peers).

🤖 Generated with Claude Code

vxfemboy and others added 12 commits July 8, 2026 21:31
…c-shaping

Approved brainstorming output for 3b. Blurs yip's residual FLOW-SHAPE
fingerprint (the statistical silhouette 3a leaves — 2-packet handshake opener +
constant 30ms Control pulse) with latency-free junk/decoy traffic, all behind
the existing obf_psk and reusing the 3a yip-obf envelope (no new crate, no
wire-format change). Three techniques: (1) Jc/Jmin/Jmax junk burst around the
handshake (automatic), (2) opt-in idle cover traffic (cover_traffic_ms, default
off), (3) jitter the un-jittered 30ms Control-feedback cadence. New JUNK_TYPE=6
ptype, silent-drop on receive (inert, zero reflection surface). Fast xorshift
PRNG for junk bodies/lengths/counts (masked anyway), getrandom only for the
nonce. Data-path timing shaping stays OUT (latency) -> deferred paranoid mode.
obf_psk absent = byte-identical. Tested by nDPI no-regression gate + a
lightweight deterministic flow-shape structural check (not an ML harness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7-task TDD plan from the approved 3b spec. Reuses the merged 3a yip-obf
envelope: (1) JUNK_TYPE=6 + a fast xorshift PRNG in yip-obf; (2) junk-datagram
builder + silent-drop JUNK_TYPE demux arm in peer_manager; (3) handshake junk
burst (Jc 3..=12 around begin_handshake); (4) opt-in idle cover traffic
(cover_traffic_ms); (5) jitter the un-jittered 30ms Control-feedback cadence;
(6) netns junk-doesn't-break-connectivity + obf-off no-regression both drivers;
(7) nDPI oracle no-regression + a lightweight deterministic flow-shape
structural check (pre-data packet count varies across sessions). Latency-free
throughout; obf_psk absent = byte-identical; junk never touches Noise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two primitives for junk/decoy packet obfuscation:
- JUNK_TYPE (const u8 = 6): packet-type discriminant for junk datagrams
- XorShift64: fast non-cryptographic PRNG for generating junk content

XorShift64 is seeded once from OS RNG, then uses pure userspace xorshift64*
algorithm for cheap byte generation. Content is keystream-masked by obfuscate(),
so randomness quality only needs to be fast, not cryptographic.

Includes 4 new tests covering type distinctness, range generation bounds,
byte filling variation, and round-trip obfuscation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds PeerManager::build_junk (random-length throwaway body, keystream-masked
under yip_obf::JUNK_TYPE) and the receive-side silent-drop arm in
deobf_ingress (both the session-key and network-key branches): a datagram
that unmasks to JUNK_TYPE is dropped inertly (no reply, no state mutation,
no fall-through). Junk emission itself lands in Tasks 3/4.
begin_handshake now returns Vec<EgressDatagram>: when obfuscation is on
and the handshake is direct (not relayed), the real Init is preceded by
a randomized Jc in [JUNK_BURST_MIN, JUNK_BURST_MAX] burst of junk
datagrams, so the flow no longer opens with a countable "2 packets then
data". Relay path and obf-off path are unchanged (single datagram,
byte-identical). Updates all 8 begin_handshake call sites from
push(dg) to extend(dgs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_egress (3b)

build_junk was pre-obfuscating its output, and every egress datagram then
flowed through obf_egress, which wrapped it again — double-obfuscating junk.
The receiver's single deobfuscate recovered a garbage ptype (the inner
envelope's random nonce byte), so junk was dropped via the generic
unrecognized-ptype path instead of the dedicated JUNK_TYPE arm.

build_junk now returns a plaintext [JUNK_TYPE][random body] datagram, like
every other producer; obf_egress performs the single wrap (network key for a
handshake-burst dst, session key for an established-peer cover dst). Updated
the Task 2/3 tests to assert the plaintext form and added an end-to-end test
that drives on_tun through the real obf_egress pass and proves every emitted
datagram recovers under exactly one deobfuscate call.
Emit one session-keyed junk datagram per interval to each Established
peer that has been idle (no real Data sent or received), so an
obfuscated flow never goes tellingly silent. Config key
cover_traffic_ms (default off, 0 rejected); PeerManager tracks
per-peer last_activity_ms/last_cover_ms and gates emission on both
being idle. No-op unless obf_psk is also configured.
…ire) (3b)

A relay-reached peer (relay == true, established via the passive
relayed_handshake_init/relayed_handshake_resp path) keeps a stale
candidate `endpoint` from before relay took over. The idle-cover block
fired at that stale address for any Established peer regardless of the
relay flag, missing the actual peer every interval and leaking periodic
junk to an unrelated address. Gate cover emission on !relay, mirroring
the real-Data egress arm above, matching Task 3's direct-path-only scope
for handshake junk. Adds tick_emits_no_cover_for_relay_peer alongside the
existing four cover tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reshuffles WHEN DataPlane::tick emits its 30 ms loss-feedback Control
packet by ±25% when obfuscation is on, reusing peer_manager::jitter_ms
(the same stored-jittered-interval pattern 3a used for retry_ms/reg_ms).
obf-off keeps the cadence byte-identical to pre-3b (exactly 30 ms) —
never delays a data packet, only the reporting timer's own schedule.
Prove 3b junk/decoy traffic doesn't break connectivity: obfuscated_ping_with_cover
(OBF_PSK + cover_traffic_ms) pings across; the existing obf-on tests
(direct/relay/punch/discovery) now exercise the automatic handshake junk burst +
Control-cadence jitter. COVER_MS env-guard in run-netns-tunnel.sh (no-op when
unset => byte-identical). Gate: 6 obf-on + 10 obf-off, both drivers (poll +
io_uring), all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…k (3b)

Confirms 3b's junk/jitter doesn't reintroduce a DPI signature: re-ran the
existing run-ndpi-oracle.sh against the 3b-junk-laden capture — still Unknown
by content, no NDPI_OBFUSCATED_TRAFFIC (unchanged, no regression).

Adds run-flowshape-check.sh, a deterministic packet-count structural check
(the flow-shape analogue of 3a's no_byte_position_is_constant): for 8
independent obf-on sessions, measures the handshake-phase datagram count via
inter-packet-gap cutoff (burst = sub-ms gaps, steady-state Control-feedback
cadence = ~10-30ms gaps) and asserts it's >2 (junk present) and takes >1
distinct value across sessions (Jc burst redrawn per handshake).

Along the way: discovered `initiate=` is a dead config key (dropped in 2a) —
both peers actually bootstrap-initiate and glare-resolve, so both sides emit
independent junk bursts rather than a single initiator; the gap-based count
is robust to that instead of assuming a fixed initiator role. Also found and
fixed a capture-timing bug where a too-short kill grace raced tcpdump's pcap
writer, silently producing empty captures.

Wires flowshape_not_obviously_constant into the netns-tunnel-test loop
(both poll and io_uring drivers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVP6NnbDMAg1iTsMMTfL86
…+junk floor (3b)

Gate (a) asserted handshake-phase count > 2 to prove "junk present," but
both peers glare-initiate at bring-up (each self-initiates; the loser's
Init still hits the wire before glare-resolution), so a junk-free
two-sided-glare handshake already yields 3 datagrams (Init(A) + Init(B) +
Resp) — a >2 threshold would pass even with junk disabled. Raise the
threshold to >4: strictly above the junk-free glare baseline (with a +1
retransmit margin) and strictly below the observed junk-present minimum
(~7-8, clipped from the ~9 theoretical floor). Rewrite the overclaiming
comments in run-flowshape-check.sh and tunnel_netns.rs to state the real
baseline and call out gate (b) as the primary non-vacuous proof of
randomization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVP6NnbDMAg1iTsMMTfL86
@vxfemboy
vxfemboy merged commit 50feb48 into main Jul 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant