Prototype pluggable transport (ad‑blocker update mimic) — request for review, guidance, and collaboration
Hello Pluggable Transports / Tor Project team, I am experimenting with pluggable transport designs and would greatly appreciate your review, security guidance, and advice on proper integration with Tor's pluggable-transport ecosystem. This email includes a short technical summary, exact prototype code (client + server), running instructions, suggested realistic hardening/padding/timing, and a minimal stdin/stdout PT wrapper skeleton so this can be adapted into a real ClientTransportPlugin. Please treat the attached code and descriptions as an educational research prototype — it is **not production-ready**. **Key goals** - Prototype a PT that mimics browser extension filter-list update checks (ad‑blocker‑like traffic) to carry an encrypted tunneled payload. - Provide a small test harness so Tor Project reviewers can run the demo locally. - Ask for guidance on whether this façade is appropriate, safe, and how best to integrate with Tor's PT interface (packaging, authentication, key exchange, deployment concerns). **Important legal & ethical note (please read)** - This code is a proof of concept for research only. Do not deploy it against third-party infrastructure, impersonate real vendors, or use domains/certificates you do not control. - I welcome explicit feedback if the Tor Project considers the approach harmful, unsafe, or unacceptable for inclusion as an official PT (I will stop development if advised). --- ## Technical summary (short) - Transport: TLS over TCP, shaped to look like browser extension update checks (e.g., `POST /updates/check`, JSON response keys `update_version`, `list_name`, `payload_b64`). - Encryption & framing: AES‑GCM (demo uses a pre-shared key). Frame format = 4‑byte big‑endian length prefix || (nonce || ciphertext+tag). Frames are base64-embedded in HTTP bodies/JSON to resemble benign payloads. - Prototype language: Python 3, asyncio, `cryptography` library for AES‑GCM. - Prototype features: TLS server + client example, framing/unframing, JSON wrapping, echo server to validate round-trip, and instructions to add jitter/padding/fragmentation. - Integration note: Prototype uses a static shared key for simplicity. Real PTs should use ephemeral ECDH or Tor-provided authentication tokens and follow Tor PT handshake requirements. --- ## Files included inline (copy/paste into files) ### 1) Server — `adblock_pt_server.py` ```python #!/usr/bin/env python3 """ Prototype "adblock-like" pluggable transport server (demo). - TLS server listening on host:port. - Accepts simple HTTP-like requests, expects base64-framed AES-GCM payloads. - Decrypts, prints, and echoes an encrypted framed reply inside a JSON update response. WARNING: demo only. Use certs/domains you control. Do not impersonate real update servers. Dependencies: cryptography """ import asyncio, ssl, argparse, json, os, struct, base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM # ----- CONFIG (demo) ----- SHARED_KEY = b'\x01' * 32 # demo key: replace with secure key exchange in production # -------------------------- def frame_message(data: bytes) -> bytes: return struct.pack('!I', len(data)) + data def unframe_stream(buf: bytearray): while True: if len(buf) < 4: break length = struct.unpack('!I', bytes(buf[:4]))[0] if len(buf) < 4 + length: break payload = bytes(buf[4:4+length]) del buf[:4+length] yield payload async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, aesgcm: AESGCM): peer = writer.get_extra_info('peername') print(f"[+] Connection from {peer}") try: data = await reader.readuntil(b"\r\n\r\n") except asyncio.IncompleteReadError: writer.close() await writer.wait_closed() return headers = data.decode(errors='ignore').split("\r\n") request_line = headers[0] if headers else "" method, path, _ = (request_line.split(" ", 2) + ["", ""])[:3] header_map = {} for h in headers[1:]: if ':' in h: k, v = h.split(':',1) header_map[k.strip().lower()] = v.strip() body = b"" if 'content-length' in header_map: try: length = int(header_map['content-length']) if length: body = await reader.readexactly(length) except Exception: pass try: raw = base64.b64decode(body) if body else b'' buf = bytearray(raw) outputs = [] for frame in unframe_stream(buf): nonce = frame[:12] ct = frame[12:] pt = aesgcm.decrypt(nonce, ct, None) print(f" -> Received framed payload (len {len(pt)}): {pt!r}") outputs.append(pt) reply_plain = b"".join(outputs) or b"PING" nonce = os.urandom(12) ct = aesgcm.encrypt(nonce, reply_plain, None) framed = nonce + ct payload = frame_message(framed) payload_b64 = base64.b64encode(payload).decode() resp_body = json.dumps({ "update_version": "2025-10-21", "list_name": "easy-filters-proto", "payload_b64": payload_b64 }).encode() resp = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + str(len(resp_body)).encode() + b"\r\n\r\n" + resp_body writer.write(resp) await writer.drain() except Exception as e: print("Error handling payload:", e) writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") await writer.drain() writer.close() await writer.wait_closed() print(f"[-] Connection from {peer} closed") async def main(host, port, certfile, keyfile): aesgcm = AESGCM(SHARED_KEY) sslctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) sslctx.load_cert_chain(certfile=certfile, keyfile=keyfile) server = await asyncio.start_server(lambda r,w: handle_client(r,w,aesgcm), host, port, ssl=sslctx) addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets) print(f"Serving on {addrs}") async with server: await server.serve_forever() if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument('--host', default='0.0.0.0') p.add_argument('--port', type=int, default=8443) p.add_argument('--cert', default='server.crt') p.add_argument('--key', default='server.key') args = p.parse_args() try: asyncio.run(main(args.host, args.port, args.cert, args.key)) except KeyboardInterrupt: print("Server stopped") Client — adblock_pt_client.py #!/usr/bin/env python3 """ Prototype client: connects over TLS, sends one framed AES-GCM payload embedded as base64 in the HTTP body, and prints decrypted framed reply from server. Dependencies: cryptography """ import asyncio, ssl, argparse, json, os, struct, base64, time from cryptography.hazmat.primitives.ciphers.aead import AESGCM SHARED_KEY = b'\x01' * 32 # demo key; replace with secure key exchange in production def frame_message(data: bytes) -> bytes: return struct.pack('!I', len(data)) + data def unframe_stream(buf: bytearray): while True: if len(buf) < 4: break length = struct.unpack('!I', bytes(buf[:4]))[0] if len(buf) < 4 + length: break payload = bytes(buf[4:4+length]) del buf[:4+length] yield payload async def run_client(host, port, cafile=None): aesgcm = AESGCM(SHARED_KEY) sslctx = ssl.create_default_context() if cafile: sslctx.load_verify_locations(cafile) else: sslctx.check_hostname = False sslctx.verify_mode = ssl.CERT_NONE reader, writer = await asyncio.open_connection(host, port, ssl=sslctx) my_payload = b"HELLO-TOR-PT: test at %d" % int(time.time()) nonce = os.urandom(12) ct = aesgcm.encrypt(nonce, my_payload, None) framed = nonce + ct payload = frame_message(framed) payload_b64 = base64.b64encode(payload).decode() body = payload_b64.encode() req = (f"POST /updates/check HTTP/1.1\r\n" f"Host: updates.adblock-proto.example\r\n" f"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ExtensionUpdate/1.2\r\n" f"Accept: application/json\r\n" f"Content-Type: application/octet-stream\r\n" f"Content-Length: {len(body)}\r\n" f"Connection: close\r\n\r\n").encode() + body writer.write(req) await writer.drain() try: header_data = await reader.readuntil(b"\r\n\r\n") except asyncio.IncompleteReadError: print("No response headers") writer.close() await writer.wait_closed() return header_text = header_data.decode(errors='ignore') content_length = 0 for line in header_text.split("\r\n"): if line.lower().startswith("content-length:"): content_length = int(line.split(":",1)[1].strip()) resp_body = b"" if content_length: resp_body = await reader.readexactly(content_length) try: j = json.loads(resp_body.decode()) if 'payload_b64' in j: server_payload_b64 = j['payload_b64'].encode() raw = base64.b64decode(server_payload_b64) buf = bytearray(raw) for frame in unframe_stream(buf): nonce = frame[:12] ct = frame[12:] pt = aesgcm.decrypt(nonce, ct, None) print("[+] Received tunnel reply:", pt) except Exception as e: print("Failed to parse server response:", e, resp_body) writer.close() await writer.wait_closed() if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument('--host', default='127.0.0.1') p.add_argument('--port', type=int, default=8443) p.add_argument('--cafile', default=None) args = p.parse_args() try: asyncio.run(run_client(args.host, args.port, args.cafile)) except KeyboardInterrupt: pass Minimal stdin/stdout PT wrapper skeleton (ClientTransportPlugin style) This skeleton demonstrates the basic pattern where Tor launches a PT binary and communicates via stdin/stdout. Real PTs follow Tor’s PT handshake protocol — this is a minimal skeleton to adapt the above transport into a tokable PT process. DO NOT use as-is for production; consult Tor docs for required messages and handshake fields. #!/usr/bin/env python3 """ Very small PT stdin/stdout skeleton (demo). Tor launches PT and exchanges control messages on stdin/stdout. This skeleton demonstrates where to tie in the upstream SOCKS<->transport logic. """ import sys, json, subprocess, os def send_response(obj): s = json.dumps(obj) sys.stdout.write(s + "\n") sys.stdout.flush() def read_request(): line = sys.stdin.readline() if not line: return None return json.loads(line.strip()) def main(): # Example: Tor may send a 'version' / 'init' style message first (real protocol differs). # Read initial message req = read_request() if req is None: return # Inspect request and respond (example handshake) send_response({"status": "ok", "proto": "adblock-proto-demo", "version": "0.1"}) # In a real wrapper: # - parse Tor's assigned socks port / client address # - start a local listener that accepts a single connection, then forward data from socks -> encrypted HTTP pseudo-updates via the client transport above. # - implement multiplexing, proper framing, timeouts, logging, and robust error handling. while True: # keep alive or wait for Tor to kill process line = sys.stdin.readline() if not line: break if __name__ == "__main__": main() Suggested realism / hardening steps (non-exhaustive) - Replace static pre-shared key with ephemeral ECDH (X25519) handshake per-session or use Tor-provided authentication tokens. - Implement per-connection padding and body-size shaping to match observed filter-list update sizes (collect benign samples to model). - Add jittered periodic checks (e.g., pseudo-update every X hours, randomized within window). - Fragment larger Tor cells across multiple pseudo-update requests; reassemble server-side. - Use valid certs for domains you control; do not impersonate legitimate extension vendors. - Implement rate-limiting, connection reuse (keep-alive), and HTTP header variability to match multiple extension implementations. - Consider how middleboxes might fingerprint TLS features (ALPN, TLS versions, ciphers); try to match common browser/TLS stacks of real extension update servers you control. - Add server-side logging that preserves privacy and limits sensitive plaintext exposure (log only lengths/timestamps, not decrypted payloads). ------------------------------ Questions I have for you / requested guidance 1. Is this façade approach acceptable for a Tor pluggable transport from the Tor Project’s perspective (network safety, abuse potential, collateral damage)? 2. Are there existing PT design constraints / packaging rules I should follow (besides the obvious obfs4/meek references)? Any docs you recommend for integrating with the ClientTransportPlugin flow and signing/release packaging? 3. Any security pitfalls I missed (e.g., observable TLS fingerprint mismatches, directory authorities concerns, recommended key exchange patterns)? Kes Pembroke
Hello Kester, Thank you for experimenting with new ideas for PTs. Quoting Kester Pembroke via anti-censorship-team (2025-10-21 17:26:16)
- Prototype a PT that mimics browser extension filter-list update checks (ad‑blocker‑like traffic) to carry an encrypted tunneled payload.
Is your idea to use existing filter-list hosting to tunnel Tor's traffic? Are those hostings public and we can put traffic on them? Or the idea is that each bridge will have their own HTTP server in their own domain hosted by themselves? If is selfhosted, How are filter-list updates different from any other web traffic? There is already two PTs using web traffic: * meek uses HTTP GET requests * webtunnel uses websockets What will be the benefit of using filter-list updates?
Questions I have for you / requested guidance
1. Is this façade approach acceptable for a Tor pluggable transport from the Tor Project’s perspective (network safety, abuse potential, collateral damage)?
2. Are there existing PT design constraints / packaging rules I should follow (besides the obvious obfs4/meek references)? Any docs you recommend for integrating with the ClientTransportPlugin flow and signing/release packaging?
Not sure, I need to understand more about it to answer this.
3. Any security pitfalls I missed (e.g., observable TLS fingerprint mismatches, directory authorities concerns, recommended key exchange patterns)?
To avoid TLS fingerprinting we use uTLS in many places: https://pkg.go.dev/github.com/refraction-networking/utls -- meskio | https://meskio.net/ -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- My contact info: https://meskio.net/crypto.txt -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- Nos vamos a Croatan.
participants (2)
-
Kester Pembroke -
meskio