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>
139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Experimental TCP outer transports. Not a production plugin or crypto scheme.
|
|
|
|
Run a client relay in front of stock ss-local's remote connection and a server
|
|
relay in front of an unchanged ss-server. TLS uses a verified experiment CA.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
from base64 import b85decode, b85encode
|
|
import contextlib
|
|
import ssl
|
|
|
|
MODES = ('raw', 'base85', 'lowpop85', 'tls')
|
|
B85 = b'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~'
|
|
LOW = bytes(sorted(range(256), key=lambda value: (value.bit_count(), value))[:85])
|
|
TO_LOW = bytes.maketrans(B85, LOW)
|
|
FROM_LOW = bytes.maketrans(LOW, B85)
|
|
BLOCK = 16384
|
|
|
|
|
|
def encode(mode, data):
|
|
if mode == 'raw':
|
|
return data
|
|
if mode == 'tls':
|
|
return len(data).to_bytes(2, 'big') + data
|
|
result = b85encode(data)
|
|
return result.translate(TO_LOW) + b'\xff' if mode == 'lowpop85' else result + b'\n'
|
|
|
|
|
|
def decode(mode, data):
|
|
if mode == 'lowpop85':
|
|
if any(value not in LOW for value in data):
|
|
raise ValueError('invalid low-popcount symbol')
|
|
data = data.translate(FROM_LOW)
|
|
return b85decode(data)
|
|
|
|
|
|
async def read_record(reader, mode):
|
|
if mode == 'raw':
|
|
return await reader.read(BLOCK)
|
|
if mode == 'tls':
|
|
length = int.from_bytes(await reader.readexactly(2), 'big')
|
|
if length > BLOCK:
|
|
raise ValueError('oversized TLS record')
|
|
return await reader.readexactly(length)
|
|
delimiter = b'\xff' if mode == 'lowpop85' else b'\n'
|
|
frame = await reader.readuntil(delimiter)
|
|
if len(frame) > (BLOCK * 5 + 3) // 4 + 1:
|
|
raise ValueError('oversized encoded record')
|
|
return decode(mode, frame[:-1])
|
|
|
|
|
|
async def pump(reader, writer, mode, encoding, initial=None):
|
|
while True:
|
|
if initial is not None:
|
|
data, initial = initial, None
|
|
else:
|
|
data = await asyncio.wait_for(reader.read(BLOCK) if encoding else read_record(reader, mode), 30)
|
|
if encoding:
|
|
writer.write(encode(mode, data))
|
|
elif data:
|
|
writer.write(data)
|
|
await writer.drain()
|
|
if not data:
|
|
if (not encoding or mode == 'raw') and writer.can_write_eof():
|
|
writer.write_eof()
|
|
return
|
|
|
|
|
|
def tls_context(role, certificate, key=None):
|
|
if role == 'server':
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(certificate, key)
|
|
else:
|
|
context = ssl.create_default_context(cafile=certificate)
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
return context
|
|
|
|
|
|
async def relay(reader, writer, role, mode, target, context=None):
|
|
upstream = None
|
|
tasks = []
|
|
try:
|
|
initial = None
|
|
if role == 'client':
|
|
# Readiness probes and idle connections must not emit empty outer
|
|
# records or TLS handshakes that contaminate the route experiment.
|
|
initial = await asyncio.wait_for(reader.read(BLOCK), 30)
|
|
if not initial:
|
|
return
|
|
kwargs = {}
|
|
if mode == 'tls' and role == 'client':
|
|
kwargs = dict(ssl=context, server_hostname='experiment.invalid', ssl_handshake_timeout=10)
|
|
remote_reader, upstream = await asyncio.wait_for(asyncio.open_connection(*target, **kwargs), 10)
|
|
tasks = [asyncio.create_task(pump(reader, upstream, mode, role == 'client', initial)),
|
|
asyncio.create_task(pump(remote_reader, writer, mode, role != 'client'))]
|
|
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
|
for task in done:
|
|
task.result()
|
|
except (OSError, ValueError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError):
|
|
pass
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
if tasks:
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
for peer in (writer, upstream):
|
|
if peer is not None:
|
|
peer.close()
|
|
with contextlib.suppress(OSError, TimeoutError):
|
|
await asyncio.wait_for(peer.wait_closed(), 3)
|
|
|
|
|
|
async def serve(role, mode, listen, target, certificate=None, key=None):
|
|
context = tls_context(role, certificate, key) if mode == 'tls' else None
|
|
kwargs = dict(ssl=context, ssl_handshake_timeout=10) if mode == 'tls' and role == 'server' else {}
|
|
return await asyncio.start_server(
|
|
lambda reader, writer: relay(reader, writer, role, mode, target, context), *listen, **kwargs)
|
|
|
|
|
|
async def main(args):
|
|
server = await serve(args.role, args.mode, (args.listen_host, args.listen_port),
|
|
(args.target_host, args.target_port), args.certificate, args.key)
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--role', choices=('client', 'server'), required=True)
|
|
parser.add_argument('--mode', choices=MODES, required=True)
|
|
parser.add_argument('--listen-host', default='127.0.0.1')
|
|
parser.add_argument('--listen-port', type=int, required=True)
|
|
parser.add_argument('--target-host', default='127.0.0.1')
|
|
parser.add_argument('--target-port', type=int, required=True)
|
|
parser.add_argument('--certificate')
|
|
parser.add_argument('--key')
|
|
asyncio.run(main(parser.parse_args()))
|