748 lines
30 KiB
Python
748 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Live End-to-End Test Suite for Portal TV (10.0.0.10:5654) and portalkit-cli.
|
|
|
|
Test Scenarios:
|
|
a) Status Verification:
|
|
- Runs portalkit-cli status 10.0.0.10:5654.
|
|
- Asserts service is online, leaf cert SHA-256 is present and matches genuine cert.
|
|
b) Direct Channel Binding Defense Verification:
|
|
- Runs portalkit-cli test-mitm 10.0.0.10:5654.
|
|
- Asserts server rejection ("wrong PIN or MITM detected") and verdict PASSED.
|
|
c) Live Network MITM Proxy Interception Test:
|
|
- Generates a rogue self-signed TLS certificate.
|
|
- Spawns an in-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888
|
|
terminating client TLS with the rogue certificate and forwarding upstream to 10.0.0.10:5654.
|
|
- Client connects to the proxy at 127.0.0.1:8888.
|
|
- Extracts the rogue cert, initiates SRP handshake, computes M1 incorporating the rogue
|
|
cert hash, and submits it through the proxy.
|
|
- Asserts Portal TV rejects handshake with HTTP 401 ("Authentication failed (wrong PIN or MITM detected)").
|
|
d) Pinned Certificate TLS Challenge Rejection (all MITM-protected endpoints):
|
|
- Client session pinned to Portal TV's genuine certificate.
|
|
- For every post-pairing pinned path (/control/*, /video.h264, /audio.aac, /control/events):
|
|
attempt through the rogue proxy must hard-fail at TLS (no HTTP bytes sent).
|
|
- portalkit-cli control against the rogue proxy must also abort before sending requests.
|
|
e) Live Rate-Limiting Enforcement:
|
|
- Initiates a pairing session on Portal TV.
|
|
- Sends 3 consecutive failed verification attempts.
|
|
- Asserts attempt counter decrements: 2 left -> 1 left -> 0 left.
|
|
- Asserts subsequent attempt is rejected due to session cancellation / wipeout.
|
|
f) Legitimate End-to-End Pairing & Camera Control:
|
|
- Initiates a pairing session on Portal TV.
|
|
- Retrieves active PIN using adb shell logcat.
|
|
- Executes portalkit-cli pair 10.0.0.10:5654 --pin <PIN>.
|
|
- Asserts pairing success and certificate pinned.
|
|
- Executes portalkit-cli control 10.0.0.10:5654 mode Desk (expects {"ok":true}).
|
|
- Reads state via control … state; asserts mode Desk.
|
|
- Restores mode to DefaultAuto.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
|
|
PORTAL_HOST = "10.0.0.10:5654"
|
|
PORTAL_IP = "10.0.0.10"
|
|
PORTAL_PORT = 5654
|
|
PORTALKIT_CLI = "/Users/zim/Projects/portaltv/mac2/PortalKit/.build/release/portalkit-cli"
|
|
GENUINE_CERT_SHA256 = "314da0083dea8e80aa5f4194e51fb40ec27f7f40d39ea3ef8782f58ca79b3826"
|
|
|
|
PROXY_HOST = "127.0.0.1"
|
|
PROXY_PORT = 8888
|
|
|
|
# Post-pairing endpoints that MUST hard-fail under a rogue TLS cert (pin enforced).
|
|
# Pairing (/auth/srp/*) intentionally accepts any leaf and relies on channel binding instead.
|
|
MITM_PROTECTED_PATHS = [
|
|
"/control/state",
|
|
"/control/mode?mode=Desk",
|
|
"/control/fixed?x=0.5&y=0.5&scale=1.0",
|
|
"/control/desk?tightness=0.5",
|
|
"/control/events",
|
|
"/video.h264",
|
|
"/audio.aac",
|
|
]
|
|
|
|
# CLI control verbs that map onto the same pinned HTTPS surface.
|
|
MITM_PROTECTED_CLI_COMMANDS = [
|
|
["state"],
|
|
["mode", "Desk"],
|
|
["fixed?x=0.5&y=0.5&scale=1.0"],
|
|
["desk?tightness=0.5"],
|
|
]
|
|
|
|
# RFC 5054 2048-bit prime group
|
|
N_HEX = (
|
|
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74"
|
|
"020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437"
|
|
"4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
|
|
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05"
|
|
"98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB"
|
|
"9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
|
|
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718"
|
|
"3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF"
|
|
)
|
|
N = int(N_HEX, 16)
|
|
g = 2
|
|
|
|
|
|
def pad256(val: int) -> bytes:
|
|
return val.to_bytes(256, byteorder="big")
|
|
|
|
|
|
SRP_K = int.from_bytes(hashlib.sha256(pad256(N) + pad256(g)).digest(), byteorder="big")
|
|
|
|
|
|
def srp_compute_client_m1(
|
|
salt_bytes: bytes,
|
|
pub_b_int: int,
|
|
pin_str: str,
|
|
tls_cert_sha256_bytes: bytes,
|
|
):
|
|
"""
|
|
Computes SRP-6a client ephemeral key A and evidence M1 incorporating tls_cert_sha256.
|
|
Returns (pad256(A).hex(), M1.hex()).
|
|
"""
|
|
a = secrets.randbelow(N - 2) + 1
|
|
A = pow(g, a, N)
|
|
u = int.from_bytes(hashlib.sha256(pad256(A) + pad256(pub_b_int)).digest(), "big")
|
|
x = int.from_bytes(hashlib.sha256(salt_bytes + pin_str.encode("utf-8")).digest(), "big")
|
|
gx = pow(g, x, N)
|
|
base = (pub_b_int - (SRP_K * gx) % N) % N
|
|
exp = a + u * x
|
|
S = pow(base, exp, N)
|
|
K = hashlib.sha256(pad256(S)).digest()
|
|
M1 = hashlib.sha256(pad256(A) + pad256(pub_b_int) + K + salt_bytes + tls_cert_sha256_bytes).digest()
|
|
return pad256(A).hex(), M1.hex()
|
|
|
|
|
|
def post_http_over_tls(host: str, port: int, path: str, payload_obj=None, timeout: float = 8.0):
|
|
"""Direct HTTPS POST request helper using raw TLS socket."""
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
raw_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
raw_s.settimeout(timeout)
|
|
raw_s.connect((host, port))
|
|
ssl_s = ctx.wrap_socket(raw_s)
|
|
|
|
body_bytes = json.dumps(payload_obj).encode("utf-8") if payload_obj is not None else b""
|
|
req_lines = [
|
|
f"POST {path} HTTP/1.1",
|
|
f"Host: {host}:{port}",
|
|
"Content-Type: application/json" if payload_obj is not None else "Accept: application/json",
|
|
f"Content-Length: {len(body_bytes)}",
|
|
"Connection: close",
|
|
"",
|
|
"",
|
|
]
|
|
req_header = "\r\n".join(req_lines).encode("utf-8")
|
|
ssl_s.sendall(req_header + body_bytes)
|
|
|
|
resp_data = b""
|
|
while True:
|
|
try:
|
|
chunk = ssl_s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp_data += chunk
|
|
except socket.timeout:
|
|
break
|
|
ssl_s.close()
|
|
|
|
header_part, body_part = resp_data.split(b"\r\n\r\n", 1)
|
|
status_line = header_part.split(b"\r\n")[0].decode("utf-8")
|
|
status_code = int(status_line.split()[1])
|
|
try:
|
|
body_json = json.loads(body_part.decode("utf-8"))
|
|
except Exception:
|
|
body_json = {"raw": body_part.decode("utf-8", errors="replace")}
|
|
return status_code, body_json
|
|
|
|
|
|
def retrieve_active_pin() -> str:
|
|
"""Retrieves active SRP PIN from Portal TV logcat."""
|
|
cmd = 'adb shell "logcat -d -s PortalService | grep \'SRP pairing started with PIN:\' | tail -1"'
|
|
out = subprocess.check_output(cmd, shell=True, text=True).strip()
|
|
match = re.search(r"SRP pairing started with PIN:\s*(\d{6})", out)
|
|
if not match:
|
|
raise ValueError(f"Could not parse PIN from logcat output: '{out}'")
|
|
return match.group(1)
|
|
|
|
|
|
class CertificatePinningMismatchError(Exception):
|
|
"""Raised when client TLS delegate rejects a server certificate mismatch."""
|
|
pass
|
|
|
|
|
|
def pinned_https_get(host: str, port: int, path: str, pinned_sha256_hex: str, timeout: float = 5.0):
|
|
"""
|
|
Mimic PortalPinnedSessionDelegate: complete TLS, evaluate leaf pin, and only
|
|
then send HTTP. On mismatch, abort with no request bytes written.
|
|
"""
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
raw_sock.settimeout(timeout)
|
|
raw_sock.connect((host, port))
|
|
ssl_sock = ctx.wrap_socket(raw_sock)
|
|
|
|
peer_der = ssl_sock.getpeercert(binary_form=True)
|
|
peer_sha256 = hashlib.sha256(peer_der).hexdigest().lower()
|
|
|
|
if peer_sha256 != pinned_sha256_hex.lower():
|
|
ssl_sock.close()
|
|
raise CertificatePinningMismatchError(
|
|
f"PortalKit TLS Pinning Mismatch! Expected: {pinned_sha256_hex}, Got: {peer_sha256}"
|
|
)
|
|
|
|
req = (
|
|
f"GET {path} HTTP/1.1\r\n"
|
|
f"Host: {host}:{port}\r\n"
|
|
f"Connection: close\r\n\r\n"
|
|
).encode("utf-8")
|
|
ssl_sock.sendall(req)
|
|
data = ssl_sock.recv(4096)
|
|
ssl_sock.close()
|
|
return data
|
|
|
|
|
|
class LiveMitmProxy:
|
|
"""
|
|
In-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888.
|
|
Terminates client TLS with a rogue self-signed certificate and forwards
|
|
traffic upstream to 10.0.0.10:5654 over genuine TLS.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
listen_host: str = PROXY_HOST,
|
|
listen_port: int = PROXY_PORT,
|
|
upstream_host: str = PORTAL_IP,
|
|
upstream_port: int = PORTAL_PORT,
|
|
):
|
|
self.listen_host = listen_host
|
|
self.listen_port = listen_port
|
|
self.upstream_host = upstream_host
|
|
self.upstream_port = upstream_port
|
|
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.key_path = os.path.join(self.temp_dir.name, "rogue_mitm.key")
|
|
self.cert_path = os.path.join(self.temp_dir.name, "rogue_mitm.crt")
|
|
self._generate_rogue_cert()
|
|
|
|
self.server_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
self.server_ctx.load_cert_chain(certfile=self.cert_path, keyfile=self.key_path)
|
|
|
|
self.upstream_ctx = ssl.create_default_context()
|
|
self.upstream_ctx.check_hostname = False
|
|
self.upstream_ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
self.server_sock = None
|
|
self.thread = None
|
|
self.running = False
|
|
self.client_requests_received = []
|
|
self.lock = threading.Lock()
|
|
|
|
def _generate_rogue_cert(self):
|
|
"""Generates a rogue self-signed RSA-2048 certificate."""
|
|
cmd = [
|
|
"openssl",
|
|
"req",
|
|
"-x509",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-keyout",
|
|
self.key_path,
|
|
"-out",
|
|
self.cert_path,
|
|
"-days",
|
|
"1",
|
|
"-nodes",
|
|
"-subj",
|
|
"/CN=RogueMitmInterceptor/O=Attacker",
|
|
]
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
|
|
@property
|
|
def request_count(self) -> int:
|
|
with self.lock:
|
|
return len(self.client_requests_received)
|
|
|
|
def clear_requests(self):
|
|
with self.lock:
|
|
self.client_requests_received.clear()
|
|
|
|
def start(self):
|
|
self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self.server_sock.bind((self.listen_host, self.listen_port))
|
|
self.server_sock.listen(25)
|
|
self.running = True
|
|
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
|
self.thread.start()
|
|
time.sleep(0.1)
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
if self.server_sock:
|
|
try:
|
|
self.server_sock.close()
|
|
except Exception:
|
|
pass
|
|
self.temp_dir.cleanup()
|
|
|
|
def _accept_loop(self):
|
|
while self.running:
|
|
try:
|
|
client_raw, _ = self.server_sock.accept()
|
|
except Exception:
|
|
break
|
|
threading.Thread(target=self._handle_client, args=(client_raw,), daemon=True).start()
|
|
|
|
def _handle_client(self, client_raw: socket.socket):
|
|
client_ssl = None
|
|
upstream_ssl = None
|
|
try:
|
|
client_ssl = self.server_ctx.wrap_socket(client_raw, server_side=True)
|
|
except Exception:
|
|
try:
|
|
client_raw.close()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
try:
|
|
client_ssl.settimeout(6.0)
|
|
initial_data = client_ssl.recv(4096)
|
|
if not initial_data:
|
|
return
|
|
|
|
with self.lock:
|
|
self.client_requests_received.append(initial_data)
|
|
|
|
# Connect upstream to genuine Portal TV
|
|
upstream_raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
upstream_raw.settimeout(6.0)
|
|
upstream_ssl = self.upstream_ctx.wrap_socket(upstream_raw)
|
|
upstream_ssl.connect((self.upstream_host, self.upstream_port))
|
|
upstream_ssl.sendall(initial_data)
|
|
|
|
# Bidirectional forwarding
|
|
def pipe(src, dst):
|
|
try:
|
|
while True:
|
|
buf = src.recv(4096)
|
|
if not buf:
|
|
break
|
|
dst.sendall(buf)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
dst.shutdown(socket.SHUT_WR)
|
|
except Exception:
|
|
pass
|
|
|
|
t1 = threading.Thread(target=pipe, args=(client_ssl, upstream_ssl), daemon=True)
|
|
t2 = threading.Thread(target=pipe, args=(upstream_ssl, client_ssl), daemon=True)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join()
|
|
t2.join()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if client_ssl is not None:
|
|
try:
|
|
client_ssl.close()
|
|
except Exception:
|
|
pass
|
|
if upstream_ssl is not None:
|
|
try:
|
|
upstream_ssl.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
client_raw.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestPortalTVLiveE2E(unittest.TestCase):
|
|
"""Automated Live End-to-End Test Suite against Portal TV."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
self_check = subprocess.run([PORTALKIT_CLI, "--help"], capture_output=True, text=True)
|
|
if self_check.returncode != 0:
|
|
raise RuntimeError(f"portalkit-cli not executable at {PORTALKIT_CLI}")
|
|
|
|
def test_01_status_verification(self):
|
|
"""
|
|
Scenario A: Status Verification
|
|
- Runs `portalkit-cli status 10.0.0.10:5654`.
|
|
- Asserts service is online, leaf cert SHA-256 is present.
|
|
"""
|
|
print("\n--- [Scenario A] Status Verification ---")
|
|
cmd = [PORTALKIT_CLI, "status", PORTAL_HOST]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
print(proc.stdout)
|
|
|
|
self.assertEqual(proc.returncode, 0, f"portalkit-cli status failed: {proc.stderr}")
|
|
self.assertIn("Service Online: YES", proc.stdout)
|
|
self.assertIn("Leaf Cert SHA-256:", proc.stdout)
|
|
|
|
# Check leaf cert matches genuine cert
|
|
leaf_match = re.search(r"Leaf Cert SHA-256:\s+([a-f0-9]{64})", proc.stdout)
|
|
self.assertIsNotNone(leaf_match, "Leaf Cert SHA-256 hex string not found in status output")
|
|
presented_leaf = leaf_match.group(1).lower()
|
|
self.assertEqual(
|
|
presented_leaf,
|
|
GENUINE_CERT_SHA256.lower(),
|
|
f"Presented leaf cert {presented_leaf} does not match expected {GENUINE_CERT_SHA256}",
|
|
)
|
|
print("✓ Service is online and genuine leaf cert SHA-256 verified.")
|
|
|
|
def test_02_direct_channel_binding_defense_verification(self):
|
|
"""
|
|
Scenario B: Direct Channel Binding Defense Verification
|
|
- Runs `portalkit-cli test-mitm 10.0.0.10:5654`.
|
|
- Asserts server rejection ("wrong PIN or MITM detected") and verdict PASSED.
|
|
"""
|
|
print("\n--- [Scenario B] Direct Channel Binding Defense Verification ---")
|
|
cmd = [PORTALKIT_CLI, "test-mitm", PORTAL_HOST]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
print(proc.stdout)
|
|
|
|
self.assertEqual(proc.returncode, 0, f"portalkit-cli test-mitm failed: {proc.stderr}")
|
|
self.assertIn("wrong PIN or MITM detected", proc.stdout)
|
|
self.assertIn("Verdict: PASSED", proc.stdout)
|
|
print("✓ Direct channel binding defense successfully verified (verdict PASSED).")
|
|
|
|
def test_03_live_network_mitm_proxy_interception(self):
|
|
"""
|
|
Scenario C: Live Network MITM Proxy Interception Test
|
|
- Generates a rogue self-signed TLS certificate.
|
|
- Spawns in-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888
|
|
terminating client TLS with rogue cert and forwarding upstream to 10.0.0.10:5654.
|
|
- Client connects to proxy at 127.0.0.1:8888.
|
|
- Extracts rogue cert, initiates SRP handshake, computes M1 incorporating rogue cert hash,
|
|
and submits through proxy.
|
|
- Asserts Portal TV rejects handshake with HTTP 401 ("Authentication failed (wrong PIN or MITM detected)").
|
|
"""
|
|
print("\n--- [Scenario C] Live Network MITM Proxy Interception Test ---")
|
|
proxy = LiveMitmProxy(listen_host=PROXY_HOST, listen_port=PROXY_PORT)
|
|
proxy.start()
|
|
print(f"Rogue MITM proxy listening on {PROXY_HOST}:{PROXY_PORT}")
|
|
|
|
try:
|
|
# 1. Connect client to proxy over TLS and extract rogue cert
|
|
client_ctx = ssl.create_default_context()
|
|
client_ctx.check_hostname = False
|
|
client_ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
s_init = client_ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
|
|
s_init.connect((PROXY_HOST, PROXY_PORT))
|
|
|
|
rogue_cert_der = s_init.getpeercert(binary_form=True)
|
|
self.assertIsNotNone(rogue_cert_der, "Failed to capture peer certificate from proxy")
|
|
rogue_cert_sha256 = hashlib.sha256(rogue_cert_der).digest()
|
|
rogue_cert_hex = rogue_cert_sha256.hex()
|
|
print(f"Captured Rogue Cert SHA-256: {rogue_cert_hex}")
|
|
self.assertNotEqual(
|
|
rogue_cert_hex.lower(),
|
|
GENUINE_CERT_SHA256.lower(),
|
|
"Rogue cert must differ from genuine Portal TV certificate",
|
|
)
|
|
|
|
# 2. Initiate SRP pairing through the proxy
|
|
req_init = (
|
|
f"POST /auth/srp/init HTTP/1.1\r\n"
|
|
f"Host: {PROXY_HOST}:{PROXY_PORT}\r\n"
|
|
f"Accept: application/json\r\n"
|
|
f"Connection: close\r\n\r\n"
|
|
).encode("utf-8")
|
|
s_init.sendall(req_init)
|
|
|
|
resp_init_data = b""
|
|
while True:
|
|
chunk = s_init.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp_init_data += chunk
|
|
s_init.close()
|
|
|
|
_, init_body = resp_init_data.split(b"\r\n\r\n", 1)
|
|
init_json = json.loads(init_body.decode("utf-8"))
|
|
pairing_id = init_json["pairingId"]
|
|
salt_bytes = bytes.fromhex(init_json["salt"])
|
|
pub_b = int(init_json["B"], 16)
|
|
print(f"Pairing initiated through proxy: pairingId={pairing_id}")
|
|
|
|
# 3. Retrieve PIN from device logcat
|
|
pin = retrieve_active_pin()
|
|
print(f"Active PIN retrieved from Portal TV: {pin}")
|
|
|
|
# 4. Compute M1 bound to the ROGUE certificate
|
|
pub_a_hex, m1_hex = srp_compute_client_m1(
|
|
salt_bytes=salt_bytes,
|
|
pub_b_int=pub_b,
|
|
pin_str=pin,
|
|
tls_cert_sha256_bytes=rogue_cert_sha256,
|
|
)
|
|
print(f"Computed M1 bound to rogue cert: {m1_hex[:16]}…")
|
|
|
|
# 5. Submit M1 through the proxy to Portal TV
|
|
s_verify = client_ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
|
|
s_verify.connect((PROXY_HOST, PROXY_PORT))
|
|
|
|
verify_payload = json.dumps({
|
|
"pairingId": pairing_id,
|
|
"A": pub_a_hex,
|
|
"M1": m1_hex,
|
|
}).encode("utf-8")
|
|
|
|
req_verify = (
|
|
f"POST /auth/srp/verify HTTP/1.1\r\n"
|
|
f"Host: {PROXY_HOST}:{PROXY_PORT}\r\n"
|
|
f"Content-Type: application/json\r\n"
|
|
f"Content-Length: {len(verify_payload)}\r\n"
|
|
f"Connection: close\r\n\r\n"
|
|
).encode("utf-8") + verify_payload
|
|
s_verify.sendall(req_verify)
|
|
|
|
resp_verify_data = b""
|
|
while True:
|
|
chunk = s_verify.recv(4096)
|
|
if not chunk:
|
|
break
|
|
resp_verify_data += chunk
|
|
s_verify.close()
|
|
|
|
verify_headers, verify_body = resp_verify_data.split(b"\r\n\r\n", 1)
|
|
status_code = int(verify_headers.split(b"\r\n")[0].split()[1])
|
|
verify_json = json.loads(verify_body.decode("utf-8"))
|
|
print(f"Server response through proxy: HTTP {status_code} {verify_json}")
|
|
|
|
# 6. Assert rejection due to channel binding mismatch
|
|
self.assertEqual(status_code, 401, f"Expected HTTP 401, got {status_code}")
|
|
rejection_message = verify_json.get("message", "")
|
|
self.assertIn("wrong PIN or MITM detected", rejection_message)
|
|
print("✓ Live network MITM proxy successfully rejected with HTTP 401 (channel binding defense held).")
|
|
finally:
|
|
proxy.stop()
|
|
|
|
def test_04_pinned_certificate_tls_challenge_rejection(self):
|
|
"""
|
|
Scenario D: every MITM-protected endpoint must hard-fail under a rogue cert.
|
|
|
|
Soft failures (system warning / continue / leak HTTP) are not acceptable:
|
|
the client must abort at TLS pin check with zero request bytes observed by
|
|
the proxy for each protected path and for portalkit-cli control verbs.
|
|
"""
|
|
print("\n--- [Scenario D] Pinned Certificate TLS Challenge Rejection (all protected endpoints) ---")
|
|
proxy = LiveMitmProxy(listen_host=PROXY_HOST, listen_port=PROXY_PORT)
|
|
proxy.start()
|
|
print(f"Rogue MITM proxy listening on {PROXY_HOST}:{PROXY_PORT}")
|
|
print(f"Protected paths under test: {len(MITM_PROTECTED_PATHS)}")
|
|
|
|
try:
|
|
for path in MITM_PROTECTED_PATHS:
|
|
with self.subTest(path=path):
|
|
proxy.clear_requests()
|
|
hard_fail = False
|
|
try:
|
|
pinned_https_get(
|
|
host=PROXY_HOST,
|
|
port=PROXY_PORT,
|
|
path=path,
|
|
pinned_sha256_hex=GENUINE_CERT_SHA256,
|
|
)
|
|
except CertificatePinningMismatchError as e:
|
|
hard_fail = True
|
|
print(f" {path}: hard-fail pin mismatch — {e}")
|
|
|
|
self.assertTrue(
|
|
hard_fail,
|
|
f"{path}: expected CertificatePinningMismatchError hard-fail; "
|
|
f"connection must not proceed past TLS (soft warning is insufficient)",
|
|
)
|
|
self.assertEqual(
|
|
proxy.request_count,
|
|
0,
|
|
f"{path}: HTTP bytes were sent despite pin mismatch "
|
|
f"({proxy.request_count} request(s) observed by rogue proxy)",
|
|
)
|
|
|
|
print("✓ All MITM-protected HTTP paths hard-failed at pin check with zero request leakage.")
|
|
|
|
for cmd_parts in MITM_PROTECTED_CLI_COMMANDS:
|
|
with self.subTest(cli=" ".join(cmd_parts)):
|
|
proxy.clear_requests()
|
|
cli_res = subprocess.run(
|
|
[PORTALKIT_CLI, "control", f"{PROXY_HOST}:{PROXY_PORT}", *cmd_parts],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
combined = (cli_res.stdout + "\n" + cli_res.stderr).lower()
|
|
print(f" portalkit-cli control {' '.join(cmd_parts)} → rc={cli_res.returncode}")
|
|
print(f" stdout: {cli_res.stdout.strip()}")
|
|
|
|
self.assertNotEqual(
|
|
cli_res.returncode,
|
|
0,
|
|
f"portalkit-cli control {' '.join(cmd_parts)} must exit non-zero on rogue proxy",
|
|
)
|
|
# Must be a hard abort (cancel / fail / pin), not a soft warning with success.
|
|
self.assertTrue(
|
|
any(
|
|
token in combined
|
|
for token in ("cancelled", "cancel", "failed", "mismatch", "pinning", "error")
|
|
),
|
|
f"portalkit-cli control {' '.join(cmd_parts)} did not report a hard failure: {combined!r}",
|
|
)
|
|
self.assertEqual(
|
|
proxy.request_count,
|
|
0,
|
|
f"portalkit-cli control {' '.join(cmd_parts)} leaked HTTP through rogue proxy "
|
|
f"({proxy.request_count} request(s))",
|
|
)
|
|
|
|
print("✓ portalkit-cli control verbs hard-failed against rogue proxy with zero request leakage.")
|
|
finally:
|
|
proxy.stop()
|
|
|
|
def test_05_live_rate_limiting_enforcement(self):
|
|
"""
|
|
Scenario E: Live Rate-Limiting Enforcement
|
|
- Initiates a pairing session on Portal TV.
|
|
- Sends 3 consecutive failed verification attempts.
|
|
- Asserts attempt counter decrements: 2 left -> 1 left -> 0 left.
|
|
- Asserts subsequent attempt is rejected due to session cancellation / wipeout.
|
|
"""
|
|
print("\n--- [Scenario E] Live Rate-Limiting Enforcement ---")
|
|
|
|
# Ensure a clean pairing state (exhaust any stale pairing session)
|
|
st_clean, init_clean = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init")
|
|
if st_clean == 200 and "pairingId" in init_clean:
|
|
pid = init_clean["pairingId"]
|
|
for _ in range(4):
|
|
st_v, _ = post_http_over_tls(
|
|
PORTAL_IP,
|
|
PORTAL_PORT,
|
|
"/auth/srp/verify",
|
|
{"pairingId": pid, "A": "01" * 256, "M1": "00" * 32},
|
|
)
|
|
if st_v == 400:
|
|
break
|
|
|
|
# 1. Initiate fresh pairing session on Portal TV
|
|
st_init, init_data = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init")
|
|
self.assertEqual(st_init, 200, f"Failed to initiate pairing: {init_data}")
|
|
pairing_id = init_data["pairingId"]
|
|
print(f"Initiated pairing session: {pairing_id}")
|
|
|
|
dummy_payload = {
|
|
"pairingId": pairing_id,
|
|
"A": "01" * 256,
|
|
"M1": "00" * 32,
|
|
}
|
|
|
|
# 2. Attempt 1 (expect 2 left)
|
|
st1, r1 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload)
|
|
print(f"Attempt 1: HTTP {st1}, resp: {r1}")
|
|
self.assertEqual(st1, 401)
|
|
self.assertEqual(r1.get("attemptsLeft"), 2)
|
|
|
|
# 3. Attempt 2 (expect 1 left)
|
|
st2, r2 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload)
|
|
print(f"Attempt 2: HTTP {st2}, resp: {r2}")
|
|
self.assertEqual(st2, 401)
|
|
self.assertEqual(r2.get("attemptsLeft"), 1)
|
|
|
|
# 4. Attempt 3 (expect 0 left and session wiped)
|
|
st3, r3 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload)
|
|
print(f"Attempt 3: HTTP {st3}, resp: {r3}")
|
|
self.assertEqual(st3, 401)
|
|
self.assertEqual(r3.get("attemptsLeft"), 0)
|
|
|
|
# 5. Subsequent Attempt 4 (expect rejected due to session cancellation / wipeout)
|
|
st4, r4 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload)
|
|
print(f"Attempt 4: HTTP {st4}, resp: {r4}")
|
|
self.assertIn(st4, [400, 401], f"Unexpected status code for wiped session: {st4}")
|
|
is_wiped = (r4.get("error") == "no_active_pairing") or ("Too many failed attempts" in r4.get("message", ""))
|
|
self.assertTrue(is_wiped, f"Subsequent attempt was not rejected due to wipeout: {r4}")
|
|
print("✓ Rate limiting verified: counter decremented 2 -> 1 -> 0 and session wiped out.")
|
|
|
|
def test_06_legitimate_end_to_end_pairing_and_camera_control(self):
|
|
"""
|
|
Scenario F: Legitimate End-to-End Pairing & Camera Control
|
|
Mutations return {"ok":true}; live mode is read back via /control/state.
|
|
"""
|
|
print("\n--- [Scenario F] Legitimate End-to-End Pairing & Camera Control ---")
|
|
|
|
# 1. Initiate pairing session on Portal TV
|
|
st_init, init_data = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init")
|
|
self.assertEqual(st_init, 200, f"Init failed: {init_data}")
|
|
print(f"Pairing session initiated: pairingId={init_data.get('pairingId')}")
|
|
|
|
# 2. Retrieve active PIN via adb logcat command
|
|
pin = retrieve_active_pin()
|
|
print(f"Active PIN retrieved from logcat: {pin}")
|
|
self.assertTrue(pin.isdigit() and len(pin) == 6, f"Invalid PIN format: {pin}")
|
|
|
|
# 3. Execute portalkit-cli pair
|
|
pair_cmd = [PORTALKIT_CLI, "pair", PORTAL_HOST, "--pin", pin]
|
|
pair_proc = subprocess.run(pair_cmd, capture_output=True, text=True)
|
|
print(pair_proc.stdout)
|
|
self.assertEqual(pair_proc.returncode, 0, f"Pairing failed: {pair_proc.stderr}")
|
|
self.assertIn("Pairing successful!", pair_proc.stdout)
|
|
self.assertIn("TLS Certificate Pinned:", pair_proc.stdout)
|
|
self.assertIn(GENUINE_CERT_SHA256.lower(), pair_proc.stdout.lower())
|
|
print("✓ Pairing successful and certificate pinned.")
|
|
|
|
# 4. Execute portalkit-cli control mode Desk (ack only)
|
|
ctrl_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "mode", "Desk"]
|
|
ctrl_proc = subprocess.run(ctrl_cmd, capture_output=True, text=True)
|
|
print(ctrl_proc.stdout)
|
|
self.assertEqual(ctrl_proc.returncode, 0, f"Control mode Desk failed: {ctrl_proc.stderr}")
|
|
self.assertIn('"ok":true', ctrl_proc.stdout.replace(" ", ""))
|
|
print("✓ Control command 'mode Desk' succeeded (HTTP 200, ack).")
|
|
|
|
# 5. Read back state
|
|
state_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "state"]
|
|
state_proc = subprocess.run(state_cmd, capture_output=True, text=True)
|
|
print(state_proc.stdout)
|
|
self.assertEqual(state_proc.returncode, 0, f"Control state failed: {state_proc.stderr}")
|
|
self.assertIn('"mode":"Desk"', state_proc.stdout)
|
|
print("✓ /control/state reports mode Desk.")
|
|
|
|
# 6. Restore mode to DefaultAuto
|
|
restore_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "mode", "DefaultAuto"]
|
|
restore_proc = subprocess.run(restore_cmd, capture_output=True, text=True)
|
|
print(restore_proc.stdout)
|
|
self.assertEqual(restore_proc.returncode, 0, f"Restore mode DefaultAuto failed: {restore_proc.stderr}")
|
|
self.assertIn('"ok":true', restore_proc.stdout.replace(" ", ""))
|
|
|
|
restore_state = subprocess.run(state_cmd, capture_output=True, text=True)
|
|
print(restore_state.stdout)
|
|
self.assertEqual(restore_state.returncode, 0)
|
|
self.assertIn('"mode":"DefaultAuto"', restore_state.stdout)
|
|
print("✓ Mode restored to DefaultAuto (ack + state).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|