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>
This commit is contained in:
Max Lv
2026-10-13 09:09:51 +08:00
committed by GitHub
co-authored by Claude
parent d52ddf2bce
commit 553cdc7945
36 changed files with 3389 additions and 1 deletions
+36
View File
@@ -52,6 +52,42 @@ class CliTests(unittest.TestCase):
self.assertIn('--outbound-address', help_result.stdout)
self.assertNotIn('--server-port', help_result.stdout)
def test_printable_salt_is_local_only_and_rejects_other_ciphers(self):
for name in self.binaries:
help_result = self.run_cli(name, '--help')
self.assertEqual('--printable-salt' in help_result.stdout, name == 'local')
if name != 'local':
self.assertEqual(self.run_cli(name, '--printable-salt').returncode, 2)
for method in ('aes-128-gcm', 'aes-256-cfb', '2022-blake3-aes-256-gcm',
'2022-blake3-chacha20-poly1305'):
with self.subTest(method=method):
result = self.run_cli('local', '-s', '127.0.0.1', '-p', '8388',
'-l', '1080', '-k', 'test-password', '-m', method,
'--printable-salt')
self.assertNotEqual(result.returncode, 0)
self.assertIn('requires chacha20-ietf-poly1305', result.stderr)
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / 'client.json'
config.write_text(json.dumps(dict(server='127.0.0.1', server_port=8388,
local_port=1080, password='test-password',
method='aes-128-gcm')))
result = self.run_cli('local', '-c', str(config), '--printable-salt')
self.assertNotEqual(result.returncode, 0)
self.assertIn('requires chacha20-ietf-poly1305', result.stderr)
# The config key alone enables it, so the cipher check still applies.
config.write_text(json.dumps(dict(server='127.0.0.1', server_port=8388,
local_port=1080, password='test-password',
method='aes-128-gcm', printable_salt=True)))
result = self.run_cli('local', '-c', str(config))
self.assertNotEqual(result.returncode, 0)
self.assertIn('requires chacha20-ietf-poly1305', result.stderr)
config.write_text(json.dumps(dict(server='127.0.0.1', server_port=8388,
local_port=1080, password='test-password',
method='aes-128-gcm', printable_salt='yes')))
result = self.run_cli('local', '-c', str(config))
self.assertNotEqual(result.returncode, 0)
self.assertIn("option 'printable_salt' must be a boolean", result.stderr)
def test_long_aliases_accept_their_arguments(self):
for name in self.binaries:
args = ['--config', 'not-loaded-before-help.json', '--cipher', 'aes-128-gcm',