mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-10-21 06:23:52 +00:00
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>
150 lines
8.0 KiB
Markdown
150 lines
8.0 KiB
Markdown
# 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.
|