Watch
0
0
Fork
You've already forked hyperhive
0

refuse tls hellos that carry encrypted client hello before squid sees them

This commit is contained in:
atlas 2026-11-13 06:23:42 +01:00 • committed by mara
commit dff662c6b3

View file

@ -216,6 +216,7 @@ pkgs.testers.runNixOSTest {
"L+ /run/hive-rootfs-check - - - - ${pkgs.writeShellScript "hive-priv-stub" ''
[ "$1" = egress-load ] && exec ${pkgs.nftables}/bin/nft -f "$2"
[ "$1" = egress-whois ] && exec ${hivePriv}/bin/hive-priv "$@"
[ "$1" = egress-ech-relay ] && exec ${hivePriv}/bin/hive-priv "$@"
exit 0
''}"
];
@ -319,7 +320,10 @@ pkgs.testers.runNixOSTest {
"agentonly.t.local"
"api.t.local"
];
environment.systemPackages = [ pkgs.openssl ];
environment.systemPackages = [
pkgs.openssl
pkgs.python3
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
{
address = "192.168.1.3";
@ -957,7 +961,7 @@ pkgs.testers.runNixOSTest {
def agent_lines():
# Log lines of requests from the agent node's addresses. The self-check
# connects from the bridge address, so its lines are not counted.
return len(re.findall(r" 192\.168\.1\.[347] [A-Z_]+/\d+ ", journal()))
return len(re.findall(r" (?:192\.168\.1|198\.18\.0)\.[347] [A-Z_]+/\d+ ", journal()))
seeded = proxy.succeed(f"cat {ALLOWED}")
bind("atlas", "logan")
@ -980,7 +984,7 @@ pkgs.testers.runNixOSTest {
with checked("a restricted agent's allowed name is tunnelled without the proxy variables, and the log names the agent"):
wait_icpt("https://forge.t.local/", "200")
assert re.search(
r"192\.168\.1\.3 TCP_TUNNEL/200 .* CONNECT forge\.t\.local:443 atlas ORIGINAL_DST/203\.0\.113\.2 ", journal()
r"198\.18\.0\.3 TCP_TUNNEL/200 .* CONNECT forge\.t\.local:443 atlas ORIGINAL_DST/203\.0\.113\.2 ", journal()
), "the HTTPS flow, as atlas"
assert icpt("http://forge.t.local/") == "200", "plain HTTP"
assert re.search(r"192\.168\.1\.3 TCP_\w+/200 .* GET http://forge\.t\.local/ atlas ", journal()), "the HTTP flow, as atlas"
@ -992,7 +996,7 @@ pkgs.testers.runNixOSTest {
with checked("a restricted agent's unlisted name is refused, intercepted, and logged as the agent's"):
assert icpt("https://other.t.local/") == "000", "HTTPS"
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* CONNECT other\.t\.local:443 atlas ", journal()), "logged as atlas"
assert re.search(r"198\.18\.0\.3 TCP_DENIED/403 .* CONNECT other\.t\.local:443 atlas ", journal()), "logged as atlas"
assert icpt("http://other.t.local/") == "403", "plain HTTP"
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* GET http://other\.t\.local/ atlas ", journal()), "logged as atlas"
# Control: the same address reaches the listed name, so the refusal is the policy's.
@ -1027,7 +1031,7 @@ pkgs.testers.runNixOSTest {
with checked("a source the bindings do not name is refused, and so is every source when they are gone"):
assert icpt("https://forge.t.local/", NOBODY_IP) == "000", "no lease for the address"
assert re.search(r"192\.168\.1\.7 TCP_DENIED/403 .* CONNECT 203\.0\.113\.2:443 - ", journal()), "logged without a user"
assert re.search(r"198\.18\.0\.7 TCP_DENIED/403 .* CONNECT 203\.0\.113\.2:443 - ", journal()), "logged without a user"
assert icpt("http://forge.t.local/", NOBODY_IP) == "403"
assert icpt("https://forge.t.local/") == "200", "control: the bound address, the same name"
# A hive whose pin file is missing: the lease names a MAC, no port holds it.
@ -1056,6 +1060,90 @@ pkgs.testers.runNixOSTest {
)
assert out.strip() == "", "bytes that are no ClientHello get no answer"
# The ClientHello a client sends, built by hand: SNI, groups, signature
# algorithms and supported versions, and with `ech` the encrypted_client_hello
# extension (0xfe0d) with the layout a real one has. It prints how the
# connection ended: the first byte of what came back, or `closed`.
agent.succeed(
"cat > /tmp/hello.py <<'PY'\n"
"import os, socket, struct, sys\n"
"mode, name, dst, src = sys.argv[1:5]\n"
"def ext(kind, data):\n"
" return struct.pack('>HH', kind, len(data)) + data\n"
"host = name.encode()\n"
"exts = ext(0, struct.pack('>HBH', len(host) + 3, 0, len(host)) + host)\n"
"exts += ext(10, struct.pack('>HHH', 4, 29, 23)) + ext(13, struct.pack('>HHH', 4, 0x0804, 0x0403))\n"
"exts += ext(43, bytes([2, 3, 4])) + ext(51, struct.pack('>H', 0))\n"
"if mode == 'ech':\n"
" exts += ext(0xFE0D, b'\\x00' + struct.pack('>HHBH', 1, 1, 7, 32) + os.urandom(32) + struct.pack('>H', 144) + os.urandom(144))\n"
"body = b'\\x03\\x03' + os.urandom(32) + b'\\x20' + os.urandom(32) + struct.pack('>H', 2) + b'\\x13\\x01' + b'\\x01\\x00'\n"
"body += struct.pack('>H', len(exts)) + exts\n"
"hello = b'\\x01' + struct.pack('>I', len(body))[1:] + body\n"
"s = socket.socket()\n"
"s.settimeout(10)\n"
"s.bind((src, 0))\n"
"s.connect((dst, 443))\n"
"s.sendall(b'\\x16\\x03\\x01' + struct.pack('>H', len(hello)) + hello)\n"
"try:\n"
" first = s.recv(1)\n"
"except OSError:\n"
" first = b'''\n"
"print(first.hex() or 'closed')\n"
"PY"
)
def send_hello(mode, src=ATLAS_IP):
return agent.succeed(f"python3 /tmp/hello.py {mode} forge.t.local 203.0.113.2 {src}").strip()
with checked("a ClientHello with the ECH extension is refused for a restricted agent and observed for a log agent, and the same hello without it is let through"):
seen = agent_lines()
# Control: the origin answers a hello without the extension (a
# handshake record, 0x16), and squid logs the connection as atlas.
assert send_hello("plain") == "16", "the origin answers the hello without ECH"
time.sleep(2)
assert agent_lines() > seen, "squid saw the connection"
seen = agent_lines()
assert send_hello("ech") == "closed", "a hello with an ECH extension gets no answer"
proxy.wait_until_succeeds(
"journalctl -u hive-egress-ech-relay.service -o cat | "
"grep -E '^hive-egress-ech-refused agent=atlas dst=203\\.0\\.113\\.2$'"
)
time.sleep(2)
assert agent_lines() == seen, "squid saw nothing of it"
# A `log` agent is observed, not refused: its ECH hello goes on to squid
# and the origin, and the relay logs what it would have done. The mode is
# what the squid include says (`acl agent_log_logan proxy_auth logan`).
seen = agent_lines()
assert send_hello("ech", LOGAN_IP) == "16", "logan's hello with ECH is let through"
proxy.wait_until_succeeds(
"journalctl -u hive-egress-ech-relay.service -o cat | "
"grep -E '^hive-egress-ech-would-refuse agent=logan dst=203\\.0\\.113\\.2$'"
)
time.sleep(2)
assert agent_lines() > seen, "squid saw logan's connection"
assert "hive-egress-ech-refused agent=logan" not in proxy.succeed(
"journalctl -u hive-egress-ech-relay.service -o cat --no-pager"
), "logan was not refused"
assert "ech-would-refuse agent=atlas" not in proxy.succeed(
"journalctl -u hive-egress-ech-relay.service -o cat --no-pager"
), "control: the restricted agent got the refusal, not the observation"
assert send_hello("plain", LOGAN_IP) == "16", "control: logan's hello without ECH"
# A source the bindings do not name is refused too, logged without a name.
assert send_hello("ech", NOBODY_IP) == "closed"
proxy.wait_until_succeeds(
"journalctl -u hive-egress-ech-relay.service -o cat | "
"grep -E '^hive-egress-ech-refused agent=- dst=203\\.0\\.113\\.2$'"
)
# Bytes that are no ClientHello: refused by the relay, with a line of their own.
out = agent.succeed(
"timeout 5 bash -c 'exec 3<>/dev/tcp/203.0.113.2/443; echo hello >&3; cat <&3' 2>/dev/null || true"
)
assert out.strip() == "", "bytes that are no ClientHello get no answer"
proxy.wait_until_succeeds(
"journalctl -u hive-egress-ech-relay.service -o cat | "
"grep -E '^hive-egress-hello-refused agent=atlas dst=203\\.0\\.113\\.2 reason=not a TLS handshake record$'"
)
with checked("the traffic that stays on the bridge or is the unrestricted agents' is not intercepted"):
seen = agent_lines()
# The gateway on the bridge address, in the stand-in's port 443.
@ -1068,6 +1156,7 @@ pkgs.testers.runNixOSTest {
assert icpt("https://other.t.local/") == "200", "an unlisted name, directly"
assert icpt("https://203.0.113.2/") == "200", "an IP literal, directly"
assert agent_lines() == seen, "squid saw nothing of it"
assert send_hello("ech") == "16", "a hello with ECH, directly: the relay is not in the path"
proxy.wait_until_succeeds(
"journalctl -k -o cat --no-pager | grep -E "
"'hive-egress-would-drop(-unmetered)? IN=eth1 OUT=eth2 .*SRC=192\\.168\\.1\\.3 DST=203\\.0\\.113\\.2 .*PROTO=TCP .*DPT=443 '"