LSB permuted

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.

Why linear order is a giveaway

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 seed is not the secret

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.

Visualization — the walk over a 64-sample cover

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.

loading default seed…

What SplitMix64 actually does

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 using SplitMix64 as the index source

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.

Cross-link

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.