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

150 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```sh
ss-local -c client.json --printable-salt
```
```json
{ "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](https://gfw.report/publications/usenixsecurity23/en/)
reported an exemption for six leading printable bytes in the first TCP payload.
Its [follow-up](https://github.com/gfw-report/usenixsecurity23-artifact) 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](https://developer.getoutline.org/vpn/advanced/prefixing/)
provides related deployment precedent. The
[AEAD specification](https://shadowsocks.org/doc/aead.html) 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:
```sh
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.
```sh
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](encoding-experiments.md) 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](measurements/printable-salt-2026-09-23/README.md).
The archived 2026-09-23 measurements used the original fixed six-byte prefix.
They do not evaluate this variable-length revision.