Files
shadowsocks-libev/docs/printable-salt.md
Max LvandClaude 553cdc7945 Add opt-in printable TCP salt experiment for ss-local (#3063)
Add `ss-local --printable-salt` / `"printable_salt": true`, disabled by
default. For each outgoing TCP salt it picks a prefix length uniformly
from 6-12 bytes and samples those bytes as printable ASCII (0x20-0x7e)
immediately before key derivation. The salt stays 32 bytes with no
framing or markers, so the wire format is unchanged and any stock
server interoperates. Only chacha20-ietf-poly1305 and aes-256-gcm are
accepted; other ciphers fail at startup. UDP, server responses, and
AEAD-2022 are untouched.

Crypto unit tests cover length distribution, empty first writes, exact
framing overhead, salt-tail preservation, byte-at-a-time decryption,
tampering and replay rejection. CLI tests check local-only exposure and
cipher rejection via flag, config key and bad config type. interop.py
gains --printable-salt and --server-bin for independent stock servers.

docs/printable-salt.md records the design, entropy bound, fingerprint
caveats and route-experiment procedure; docs/encoding-experiments.md
and tests/experiments/ hold the Base85/lowpop85/TLS outer-transport
prototypes. docs/measurements/printable-salt-2026-09-23/ archives the
sanitized results of the aborted field trials.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 09:09:51 +08:00

8.0 KiB
Raw Permalink Blame History

Experimental printable TCP salt

ss-local --printable-salt chooses a prefix length uniformly from 6–12 bytes for each outgoing TCP salt, then replaces that prefix with independent, uniformly sampled printable ASCII bytes (0x20–0x7e). It is disabled by default and accepts only chacha20-ietf-poly1305 and aes-256-gcm, including when the cipher comes from a configuration file or URL. Other ciphers fail at startup. Enable it with the CLI flag or the printable_salt configuration key; either one turns it on, and the key is ignored by the other binaries.

ss-local -c client.json --printable-salt
{ "server": "example.com", "server_port": 8388, "password": "...",
  "method": "chacha20-ietf-poly1305", "printable_salt": true }

The salt remains 32 bytes. The remaining 20–26 bytes retain their original secure random values. Length sampling uses libsodium 6 + randombytes_uniform(7); character sampling uses randombytes_uniform(95) immediately before key derivation and transmission on the first nonempty TCP encryption call. Empty writes do not consume the salt; later writes do not change it. For a selected length L, the conditional salt entropy is (32 - L) * 8 + L * log2(95), at least 238.84 bits (at L = 12). This is also a conservative min-entropy bound for the mixture; the random length is not an independent secret field whose entropy can simply be added. Salt uniqueness remains essential. There are no extra framing bytes, messages, or round trips.

UDP salt generation, server responses, authenticated framing, replay rejection, and AEAD-2022 are unchanged. No SIP003 plugin or fixed protocol marker is added. This is not TLS impersonation, nor a promise of censorship resistance.

The prefix introduces a statistical fingerprint: every enabled connection starts with six printable bytes, versus (95 / 256)^6, approximately 0.26%, for a uniformly random salt. The bytes vary independently, but the printable property is fixed. The constrained span now varies; the untouched tail may also start with printable bytes, so there is no explicit boundary marker. The 6–12 range is an experimental tradeoff, not an optimized or measured defense. Increasing the span can strengthen other ASCII statistics even as the boundary varies. A classifier could combine it with subsequent encrypted bytes, packet lengths, endpoint information, or probing. High salt entropy does not imply low detectability.

Rationale and limits

The USENIX Security 2023 study reported an exemption for six leading printable bytes in the first TCP payload. Its follow-up reports that the measured dynamic blocking stopped in March 2023. This is historical evidence; present-day behavior must be measured on the route of interest. Outline prefixing provides related deployment precedent. The AEAD specification describes salt-based key derivation and the requirement for unique salts.

An application write does not determine TCP packet boundaries. Packet captures must establish whether the actual first transmitted payload contains all six bytes. This experiment does not establish protection against every active probe, long-term blocking, or other classifiers.

Local reproduction

Build an independent stock checkout from the base revision before applying this change, then configure/build this branch with the same toolchain:

cmake -S . -B build-printable -G Ninja -DSS_DEPENDENCY_MODE=system \
  -DWITH_STATIC=OFF -DWITH_DOC_MAN=OFF -DWITH_DOC_HTML=OFF
cmake --build build-printable --parallel
ctest --test-dir build-printable --output-on-failure
python3 tests/interop.py --self --bin build-printable/bin \
  --server-bin build-printable-stock/bin/ss-server --printable-salt
python3 tests/interop.py --bin build-printable/bin --printable-salt
python3 tests/interop.py --self --bin build-printable/bin
python3 tests/stress_test.py --bin build-printable/bin --size 10
uvx ruff==0.15.6 check --select E9,F63,F7,F82 tests scripts
python3 scripts/check_cli_docs.py
python3 -m unittest discover -s tests -p test_cli_docs.py

The crypto tests exercise disabled/enabled behavior, empty first writes, unchanged salt tails, exact framing length, subsequent writes, byte-at-a-time decryption, tampering, and replay rejection. CLI tests check cipher rejection and local-only exposure. Interoperability tests verify concurrent bidirectional TCP and UDP; the default full suite covers all supported AEAD-2022 variants too.

Route experiment

tests/printable_salt_experiment.py runs two 30-minute phases against isolated instances of a stock remote ssserver. It requires Linux with Python, tcpdump, /usr/local/bin/ssserver and /usr/local/bin/sslocal, verified SSH host trust, two unused reachable high ports, and local passwordless access to tcpdump through sudo. Inspect listeners and firewall rules first. The runner does not modify firewalls or existing services. Its remote echo origin binds to loopback only.

python3 tests/printable_salt_experiment.py \
  --host root@SERVER --host-key-alias VERIFIED_KNOWN_HOST \
  --address SERVER_IPV4 --interface en0 \
  --stock-bin build-printable-stock/bin/ss-local \
  --modified-bin build-printable/bin/ss-local \
  --ports 32181 32182 --output build-artifacts/printable-salt/route
python3 tests/check_printable_pcap.py \
  build-artifacts/printable-salt/route/client.pcap --ports 32181 32182 \
  --events build-artifacts/printable-salt/route/events.jsonl

Fresh credentials are generated for each run. Proxy environment variables are removed from child processes. Clients swap server ports between phases. Each mode attempts a verified 1 KiB echo transfer every five seconds and ten 10 MiB echo transfers per phase. Upload and download run concurrently. Every 30 seconds, an SSH round trip verifies management health, process health, local listeners, and a verified transfer through each server using server-local stock clients. The run stops on a failed health check. Remote processes have a 65-minute watchdog and are stopped when their controlling SSH input closes or no control command arrives for 60 seconds. Normal cleanup removes experimental processes and temporary configs and retrieves packet captures.

JSONL results record transfer success, error/timeout/reset messages, integrity failures, elapsed time, and effective payload throughput. socks_handshake_ms is local SOCKS setup time, not remote connection latency. Use small-transfer completion time for an end-to-end latency measure. Throughput is one-way payload bytes divided by bidirectional echo completion time; it is not link capacity. Large tests share the route with each other and the scheduled small tests.

Captures use a 160-byte snapshot to retain TCP headers and salt prefixes while limiting size. Both packet captures and the raw results remain in a private local output directory; do not publish raw captures without reviewing address metadata. The packet checker accepts a first payload only after observing its SYN, checks its sequence number, and evaluates bytes in the packet itself.

Call a result promising only when modified traffic repeatedly succeeds while stock traffic fails, including after crossover, with healthy server-local controls. Persistent port blocking makes crossover inconclusive. If both work, report compatibility and measured performance only. If both fail, investigate captured failures before choosing another modification. A one-hour run cannot establish long-term GFW resistance.

Additional experiments

See outer-transport experiments for Base85, low-popcount encoding, and verified TLS prototypes. These require matching relays at both endpoints and are separate from the production CLI flag.

Recorded results: 2026-09-23 measurements and limitations.

The archived 2026-09-23 measurements used the original fixed six-byte prefix. They do not evaluate this variable-length revision.