mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:25:20 +00:00
Add runtime Tor-egress self-check (fail-closed) for Nostr relays
Runtime-verified whether Apple's URLSession honors connectionProxyDictionary SOCKS settings for Nostr relay traffic (plain HTTPS + URLSessionWebSocketTask), since the app routes all Nostr traffic through Arti's local SOCKS5 proxy solely via those keys and Apple does not officially support SOCKS for URLSession on iOS. Verification harness (scripts/tor-egress-verification/): a minimal SOCKS5 CONNECT proxy that logs arriving connections, plus a Swift probe that runs an HTTPS GET and a WebSocket ping through a URLSession configured with the proxy dict. Result: on macOS (kCFNetworkProxiesSOCKS* constants) and the iOS simulator (raw "SOCKSProxy"/"SOCKSPort" string keys, the exact app path) BOTH HTTP and WebSocket are proxied, do remote DNS through the proxy, and the proxied session is fail-closed (every request errors when the SOCKS proxy is down). The feared direct-egress leak did not reproduce on the tested platforms. Defense-in-depth for the platform Apple does not guarantee (physical iOS): add TorEgressVerifier, which performs a canary request through the proxied session and asserts the exit is a Tor node (check.torproject.org IsTor==true), positively detecting a direct egress even if the OS silently ignored the proxy. Policy: verifiedTor allows (cached for a TTL); notTor refuses (leak detected, never open relays); unreachable allows-with-warning (session stays fail-closed by construction). Wired into TorManager.awaitEgressReady() and required by both NostrRelayManager and GeoRelayDirectory before opening connections. Cache is invalidated on Tor restart/dormant/shutdown. Never falls back to a direct connection. Probe is injected so the policy/caching is unit-tested offline; the live-network harness lives under scripts/ and is not run by CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Standalone harness: does Apple's URLSession honor connectionProxyDictionary
|
||||
// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask?
|
||||
//
|
||||
// Build: swiftc -O proxy_probe.swift -o proxy_probe
|
||||
// Usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]
|
||||
//
|
||||
// Prints a single RESULT line: RESULT <mode> <keyStyle> <outcome> <detail>
|
||||
// The caller correlates this with the SOCKS proxy's connection log to decide
|
||||
// whether the request was proxied.
|
||||
#if canImport(CFNetwork)
|
||||
import CFNetwork
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count >= 4 else {
|
||||
FileHandle.standardError.write(Data("usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]\n".utf8))
|
||||
exit(2)
|
||||
}
|
||||
let mode = args[1]
|
||||
let keyStyle = args[2]
|
||||
let proxyPort = Int(args[3]) ?? 19999
|
||||
let host = "127.0.0.1"
|
||||
|
||||
func makeProxyDict() -> [AnyHashable: Any] {
|
||||
switch keyStyle {
|
||||
#if os(macOS)
|
||||
case "cf":
|
||||
// The exact constants the app uses on macOS.
|
||||
return [
|
||||
kCFNetworkProxiesSOCKSEnable as String: 1,
|
||||
kCFNetworkProxiesSOCKSProxy as String: host,
|
||||
kCFNetworkProxiesSOCKSPort as String: proxyPort
|
||||
]
|
||||
#endif
|
||||
default:
|
||||
// The exact raw string keys the app uses on iOS.
|
||||
return [
|
||||
"SOCKSEnable": 1,
|
||||
"SOCKSProxy": host,
|
||||
"SOCKSPort": proxyPort
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = false
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
cfg.connectionProxyDictionary = makeProxyDict()
|
||||
let session = URLSession(configuration: cfg)
|
||||
|
||||
func emit(_ outcome: String, _ detail: String) {
|
||||
print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)")
|
||||
exit(outcome == "ERROR" ? 1 : 0)
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
|
||||
if mode == "http" {
|
||||
let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
let task = session.dataTask(with: target) { data, resp, err in
|
||||
if let err = err {
|
||||
emit("ERROR", "\(err.localizedDescription)")
|
||||
} else if let http = resp as? HTTPURLResponse {
|
||||
emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)")
|
||||
} else {
|
||||
emit("OK", "bytes=\(data?.count ?? 0)")
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
} else {
|
||||
// WebSocket
|
||||
let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")!
|
||||
let ws = session.webSocketTask(with: target)
|
||||
ws.resume()
|
||||
// A successful ping proves the TLS+WS handshake completed end-to-end.
|
||||
ws.sendPing { err in
|
||||
if let err = err {
|
||||
emit("ERROR", "\(err.localizedDescription)")
|
||||
} else {
|
||||
emit("OK", "ws-ping-ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global watchdog so we never hang.
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 25) {
|
||||
emit("ERROR", "timeout")
|
||||
}
|
||||
sem.wait()
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Tor-egress proxy-honoring verification on macOS.
|
||||
#
|
||||
# For each (request-type x key-style) it runs two experiments:
|
||||
# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows)
|
||||
# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED?
|
||||
# If it succeeds with no proxy, egress went DIRECT (proxy ignored).
|
||||
# If it fails, the proxy setting is being enforced (fail-closed).
|
||||
#
|
||||
# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down
|
||||
# DIRECT = no connection at proxy OR succeeds when proxy down
|
||||
set -u
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT=19999
|
||||
DEADPORT=19998 # nothing listens here
|
||||
LOG="$(mktemp -t sockslog)"
|
||||
BIN="$(mktemp -t proxyprobe)"
|
||||
|
||||
echo "== building swift probe =="
|
||||
swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; }
|
||||
|
||||
echo "== starting SOCKS proxy on $PORT =="
|
||||
: > "$LOG"
|
||||
python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 &
|
||||
PROXY_PID=$!
|
||||
trap 'kill $PROXY_PID 2>/dev/null' EXIT
|
||||
# wait for READY
|
||||
for _ in $(seq 1 50); do
|
||||
grep -q READY /tmp/socksproxy.out 2>/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
run_case() {
|
||||
local mode="$1" key="$2"
|
||||
# Experiment A: proxy up, watch log
|
||||
local before after target
|
||||
before=$(wc -l < "$LOG" | tr -d ' ')
|
||||
local outA
|
||||
outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||
sleep 0.3
|
||||
after=$(wc -l < "$LOG" | tr -d ' ')
|
||||
local proxied="NO"
|
||||
if [ "$after" -gt "$before" ]; then proxied="YES"; fi
|
||||
local newlines
|
||||
newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|')
|
||||
|
||||
# Experiment B: proxy down (dead port), same request
|
||||
local outB
|
||||
outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "CASE mode=$mode key=$key"
|
||||
echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]"
|
||||
echo " B(proxy down): $outB"
|
||||
# verdict
|
||||
local a_ok b_ok
|
||||
a_ok=$(echo "$outA" | awk '{print $4}')
|
||||
b_ok=$(echo "$outB" | awk '{print $4}')
|
||||
local verdict="UNKNOWN"
|
||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi
|
||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi
|
||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi
|
||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi
|
||||
echo " VERDICT: $verdict"
|
||||
}
|
||||
|
||||
for mode in http ws; do
|
||||
for key in cf raw; do
|
||||
run_case "$mode" "$key"
|
||||
done
|
||||
done
|
||||
echo "========================================"
|
||||
echo "raw proxy log:"; cat "$LOG" | tr '\t' ' '
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's
|
||||
URLSession actually honors `connectionProxyDictionary` SOCKS settings for
|
||||
different request types (plain HTTPS vs URLSessionWebSocketTask).
|
||||
|
||||
Behavior:
|
||||
- Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays
|
||||
bytes bidirectionally to the real destination.
|
||||
- Every accepted CONNECT is appended to a log file as one line:
|
||||
<iso8601>\tCONNECT\t<host>:<port>
|
||||
- Any raw connection that is NOT valid SOCKS5 is logged as:
|
||||
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
|
||||
(this catches the feared case where URLSession sends a raw TLS/HTTP
|
||||
ClientHello straight at the proxy port instead of a SOCKS greeting).
|
||||
|
||||
If a request egresses DIRECTLY (proxy ignored), nothing is logged at all.
|
||||
|
||||
Usage: socks5_probe_proxy.py <listen_port> <log_file>
|
||||
"""
|
||||
import selectors
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
LOG_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def log(logfile, kind, detail):
|
||||
line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n"
|
||||
with LOG_LOCK:
|
||||
with open(logfile, "a") as f:
|
||||
f.write(line)
|
||||
sys.stderr.write("[proxy] " + line)
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def recv_exact(sock, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def handle(client, logfile):
|
||||
client.settimeout(15)
|
||||
try:
|
||||
# SOCKS5 greeting: VER=0x05, NMETHODS, METHODS...
|
||||
head = recv_exact(client, 2)
|
||||
if not head:
|
||||
return
|
||||
if head[0] != 0x05:
|
||||
# Not SOCKS5 at all — this is the smoking gun for a direct egress
|
||||
# that mistakenly hit the proxy port. Log the first bytes.
|
||||
rest = b""
|
||||
try:
|
||||
client.setblocking(False)
|
||||
rest = client.recv(64)
|
||||
except Exception:
|
||||
pass
|
||||
log(logfile, "NON_SOCKS", (head + rest).hex())
|
||||
return
|
||||
nmethods = head[1]
|
||||
if nmethods:
|
||||
recv_exact(client, nmethods)
|
||||
# Reply: no authentication required
|
||||
client.sendall(b"\x05\x00")
|
||||
|
||||
# Request: VER, CMD, RSV, ATYP, ADDR, PORT
|
||||
req = recv_exact(client, 4)
|
||||
if not req or req[1] != 0x01: # only CONNECT
|
||||
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
atyp = req[3]
|
||||
if atyp == 0x01: # IPv4
|
||||
addr = socket.inet_ntoa(recv_exact(client, 4))
|
||||
elif atyp == 0x03: # domain
|
||||
ln = recv_exact(client, 1)[0]
|
||||
addr = recv_exact(client, ln).decode("ascii", errors="replace")
|
||||
elif atyp == 0x04: # IPv6
|
||||
addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16))
|
||||
else:
|
||||
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
port = int.from_bytes(recv_exact(client, 2), "big")
|
||||
|
||||
log(logfile, "CONNECT", f"{addr}:{port}")
|
||||
|
||||
# Connect to the real destination and reply success.
|
||||
try:
|
||||
remote = socket.create_connection((addr, port), timeout=15)
|
||||
except Exception as e:
|
||||
log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}")
|
||||
client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
|
||||
relay(client, remote)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def relay(a, b):
|
||||
a.setblocking(False)
|
||||
b.setblocking(False)
|
||||
sel = selectors.DefaultSelector()
|
||||
sel.register(a, selectors.EVENT_READ, b)
|
||||
sel.register(b, selectors.EVENT_READ, a)
|
||||
try:
|
||||
while True:
|
||||
events = sel.select(timeout=30)
|
||||
if not events:
|
||||
break
|
||||
for key, _ in events:
|
||||
src = key.fileobj
|
||||
dst = key.data
|
||||
try:
|
||||
data = src.recv(65536)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
continue
|
||||
except Exception:
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
dst.sendall(data)
|
||||
except Exception:
|
||||
return
|
||||
finally:
|
||||
sel.close()
|
||||
for s in (a, b):
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: socks5_probe_proxy.py <port> <logfile>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
port = int(sys.argv[1])
|
||||
logfile = sys.argv[2]
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", port))
|
||||
srv.listen(64)
|
||||
sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n")
|
||||
sys.stderr.flush()
|
||||
print("READY", flush=True)
|
||||
while True:
|
||||
client, _ = srv.accept()
|
||||
threading.Thread(target=handle, args=(client, logfile), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user