Add post-quantum remote auth key exchange
Testing / test (push) Successful in 32s

This commit is contained in:
2026-05-05 10:34:28 +02:00
parent 30a42ba6d5
commit 98396a7c7e
7 changed files with 229 additions and 72 deletions
+75
View File
@@ -75,6 +75,20 @@ class TestChallenge:
client.close()
t.join(timeout=2)
def test_challenge_advertises_post_quantum_kex_when_available(self, tmp_path, monkeypatch):
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
client, server = _pair()
t = _spawn(server, path)
challenge = _recv_framed(client)
assert challenge["pq_kex"] == {"alg": "ML-KEM-768", "public_key": b"fake-public".hex()}
client.close()
t.join(timeout=2)
# ── rejection paths ────────────────────────────────────────────────────────────
@@ -142,6 +156,27 @@ class TestRejection:
client.close()
t.join(timeout=2)
def test_missing_post_quantum_kex_rejected_when_required(self, tmp_path, monkeypatch):
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
priv_path = tmp_path / "client.pem"
priv_path.write_bytes(pem)
priv = load_private_key(priv_path)
client, t, challenge = self._connect(path)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.4", "pubkey": pub}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "post-quantum" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_oversized_message_rejected(self):
client, server = _pair()
t = _spawn(server, None)
@@ -227,6 +262,46 @@ class TestAuthSuccess:
client.close()
t.join(timeout=2)
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, path),
daemon=True,
)
t.start()
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
msg = {
"id": "x",
"command": "tabs.list",
"args": {},
"user_agent": FAKE_UA,
"pubkey": pub,
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
}
msg["sig"] = sign(priv, nonce, msg, b"pq-secret").hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert "unauthorized" not in resp.get("error", "").lower()
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)