Same bit-flipping as linear — but instead of walking cover bytes 0, 1, 2, 3 in order we visit them in a passphrase-seeded pseudo-random order. The payload no longer occupies a contiguous prefix of the carrier, which is the whole point.
Steganalysis tools like StegExpose or an A/B chi-square on the
LSB plane ask a simple question: "does the distribution of 0s and 1s in this
fixed-size window look like natural image noise, or like a forced ~50/50
coin flip?" A natural cover has some bias per region (darker areas have
more 0s in the low bits, textured regions have more 1s, etc). A payload
overwriting consecutive LSBs forces that window to ~50/50 regardless.
With linear embedding, the first framed_bits samples
are the payload region and everything after is natural cover. Chi-square
with a sliding window lights up a bright line at the boundary. The detector
doesn't need to decrypt anything — it just reports "there's payload in this
prefix."
Permuting the walk order spreads the payload across the entire carrier. Every local window now contains a proportional mix of payload bits and untouched bits, so no single region reads as statistically forced. Across the whole carrier the LSB distribution is the same as linear (~50/50 of payload bits); the attacker just can't tell which bits are payload without the seed.
The permutation is defense-in-depth, not the confidentiality story. In rsteg, the 64-bit SplitMix64 seed comes from the low 64 bits of the Argon2id-derived key material (spec 06). An attacker who has the passphrase has the key and the seed — but someone who only has the stego file has neither.
The actual confidentiality bound is the AEAD: even if an attacker somehow guessed the permutation, they'd still face XChaCha20-Poly1305 with a 256-bit key derived via 64 MiB of Argon2id — which is what the AEAD page is about.
Enter a 64-bit hex seed. The page posts to
POST /api/algo/permutation, the server runs
SplitMix64 + Fisher–Yates exactly as embed does, and returns the
visit order. Arrows connect step i to step i+1 for the
first N steps. Try a few seeds: the first visits are wildly different, but
every run covers all 64 cells exactly once.
SplitMix64 is a tiny splittable PRNG — ~10 lines of straight integer arithmetic,
no branches, no state bigger than a single u64. rsteg uses it
because it's deterministic across platforms, has no dependency footprint, and
is easily re-implementable from the spec. It is not cryptographic — a
few hundred consecutive outputs are enough to reconstruct the seed — but
that's fine: the seed's secrecy rides on the Argon2id key derivation above
it, not on SplitMix64.
From crates/rsteg-core/src/prng.rs:
state = state.wrapping_add(0x9E3779B97F4A7C15);
advance by golden-ratio constant (φ × 2⁶⁴)
z = state;
snapshot for mixing
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
bit-mix + multiply — diffuses high bits into low
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
second mixing round
z ^ (z >> 31)
final xor-shift — this is the emitted output
The two multiplicative constants come from Stafford's Mix13 — published analysis showing they minimize bit-bias across their reachable period. Period is 2⁶⁴: any 64-bit seed produces a cycle through all 2⁶⁴ states before repeating.
Fisher–Yates is the canonical O(n) in-place uniform shuffle. For a slice
indices of length n:
for i in (1..n).rev() {
let j = prng.bounded(i + 1); // uniform in 0..=i
indices.swap(i, j);
}
Each iteration picks a target j uniformly from the unsorted prefix
0..=i and swaps it to position i. After n−1
iterations every position holds a uniformly-chosen cell.
prng.bounded(n) uses Lemire's
fast-mod trick to avoid modulo bias when n is not a power of
two — important: a biased shuffle would silently concentrate payload bits
in some positions more than others, giving the steganalyser exactly the
window it wanted.
The permuted choice is recorded in bit 2 of the
PayloadHeader.flags field. Extract reads the flag, reconstructs
the seed from the same Argon2id stream, runs the same Fisher–Yates, and
reads LSBs in the visit order. See
the PayloadHeader page.