mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-10-21 14:33:55 +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>
131 lines
5.8 KiB
Python
131 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Codec boundaries, fragmentation, bad records and relay half-close tests."""
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
|
|
from transport import BLOCK, MODES, decode, encode, read_record, serve
|
|
|
|
|
|
class CodecTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_fragmented_records(self):
|
|
for mode in MODES[1:]:
|
|
for size in (0, 1, 2, 3, 4, 5, 31, 32, BLOCK):
|
|
with self.subTest(mode=mode, size=size):
|
|
data = os.urandom(size)
|
|
wire = encode(mode, data)
|
|
reader = asyncio.StreamReader()
|
|
task = asyncio.create_task(read_record(reader, mode))
|
|
for index in range(0, len(wire), 7):
|
|
reader.feed_data(wire[index:index + 7])
|
|
await asyncio.sleep(0)
|
|
reader.feed_eof()
|
|
self.assertEqual(await task, data)
|
|
|
|
async def test_coalesced_records(self):
|
|
for mode in MODES[1:]:
|
|
reader = asyncio.StreamReader()
|
|
reader.feed_data(encode(mode, b'one') + encode(mode, b'two') + encode(mode, b''))
|
|
for expected in (b'one', b'two', b''):
|
|
self.assertEqual(await read_record(reader, mode), expected)
|
|
|
|
async def test_invalid_and_truncated(self):
|
|
for mode in MODES[1:]:
|
|
reader = asyncio.StreamReader()
|
|
reader.feed_data(encode(mode, b'something')[:-1])
|
|
reader.feed_eof()
|
|
with self.assertRaises(asyncio.IncompleteReadError):
|
|
await read_record(reader, mode)
|
|
for mode, invalid in [('base85', b'/'), ('lowpop85', b'\xff')]:
|
|
with self.assertRaises(ValueError):
|
|
decode(mode, invalid)
|
|
reader = asyncio.StreamReader()
|
|
reader.feed_data((BLOCK + 1).to_bytes(2, 'big'))
|
|
with self.assertRaises(ValueError):
|
|
await read_record(reader, 'tls')
|
|
|
|
async def test_empty_client_does_not_connect_upstream(self):
|
|
calls = []
|
|
async def connected(reader, writer):
|
|
calls.append(True)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
target = await asyncio.start_server(connected, '127.0.0.1', 0)
|
|
async with target:
|
|
for mode in MODES[:-1]:
|
|
client = await serve('client', mode, ('127.0.0.1', 0), target.sockets[0].getsockname()[:2])
|
|
async with client:
|
|
_, writer = await asyncio.open_connection(*client.sockets[0].getsockname()[:2])
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
await asyncio.sleep(.03)
|
|
self.assertEqual(calls, [])
|
|
|
|
async def test_tls_half_close_and_certificate_verification(self):
|
|
if not shutil.which('openssl'):
|
|
self.skipTest('openssl CLI is needed for temporary test certificates')
|
|
from local_interop import certificate
|
|
calls = []
|
|
async def reverse(reader, writer):
|
|
calls.append(True)
|
|
data = await reader.read()
|
|
writer.write(data[::-1])
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
good, bad = Path(temp) / 'good', Path(temp) / 'bad'
|
|
good.mkdir()
|
|
bad.mkdir()
|
|
cert, key = certificate(good)
|
|
wrong, _ = certificate(bad)
|
|
origin = await asyncio.start_server(reverse, '127.0.0.1', 0)
|
|
server = await serve('server', 'tls', ('127.0.0.1', 0),
|
|
origin.sockets[0].getsockname()[:2], str(cert), str(key))
|
|
async with origin, server:
|
|
for trusted, succeeds in ((cert, True), (wrong, False)):
|
|
client = await serve('client', 'tls', ('127.0.0.1', 0),
|
|
server.sockets[0].getsockname()[:2], str(trusted))
|
|
async with client:
|
|
reader, writer = await asyncio.open_connection(*client.sockets[0].getsockname()[:2])
|
|
writer.write(b'certificate-and-half-close-test')
|
|
writer.write_eof()
|
|
result = await asyncio.wait_for(reader.read(), 5)
|
|
self.assertEqual(result, b'tset-esolc-flah-dna-etacifitrec' if succeeds else b'')
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
self.assertEqual(len(calls), 1, 'bad certificate must not reach the backend')
|
|
|
|
async def test_half_close_and_concurrent_relay(self):
|
|
async def reverse(reader, writer):
|
|
data = await reader.read()
|
|
writer.write(data[::-1])
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
origin = await asyncio.start_server(reverse, '127.0.0.1', 0)
|
|
target = origin.sockets[0].getsockname()[:2]
|
|
async with origin:
|
|
for mode in MODES[:-1]:
|
|
server = await serve('server', mode, ('127.0.0.1', 0), target)
|
|
remote = server.sockets[0].getsockname()[:2]
|
|
client = await serve('client', mode, ('127.0.0.1', 0), remote)
|
|
address = client.sockets[0].getsockname()[:2]
|
|
async def check():
|
|
reader, writer = await asyncio.open_connection(*address)
|
|
data = os.urandom(BLOCK * 3 + 7)
|
|
writer.write(data)
|
|
writer.write_eof()
|
|
self.assertEqual(await asyncio.wait_for(reader.read(), 5), data[::-1])
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
async with server, client:
|
|
await asyncio.gather(*(check() for _ in range(4)))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|