feat: add remote trust and server identity pinning
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints. - Add remote add/list/remove commands for explicit endpoint persistence. - Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing. - Add URL glob matching for tabs filter and count with extension tests. - Add n8n credential pinning for server public keys or SHA256 fingerprints. - Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes. - Bump browser-cli to 0.16.4 and n8n node to 0.3.1. - Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
This commit is contained in:
@@ -649,6 +649,55 @@ def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_pat
|
||||
"extensionVersion": "0.15.6",
|
||||
}]
|
||||
|
||||
def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypatch, tmp_path):
|
||||
"""`browser-cli --remote host clients` should list all profiles, not auto-route and fail as ambiguous."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
targets = [
|
||||
BrowserTarget(
|
||||
profile="main",
|
||||
display_name="browser-host.example:main",
|
||||
socket_path="",
|
||||
remote="browser-host.example:8765",
|
||||
browser_name="Chrome",
|
||||
display_group="browser-host.example",
|
||||
version="149.0.0.0",
|
||||
extension_version="0.16.4",
|
||||
),
|
||||
BrowserTarget(
|
||||
profile="work",
|
||||
display_name="browser-host.example:work",
|
||||
socket_path="",
|
||||
remote="browser-host.example:8765",
|
||||
browser_name="Firefox",
|
||||
display_group="browser-host.example",
|
||||
version="151.0",
|
||||
extension_version="0.16.4",
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
||||
monkeypatch.setattr(core, "send_command", lambda *a, **k: pytest.fail("clients.list must not auto-route for cached targets"))
|
||||
|
||||
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert [row["profile"] for row in rows] == ["browser-host.example:main", "browser-host.example:work"]
|
||||
assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
|
||||
|
||||
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", version="1"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", version="1"),
|
||||
]
|
||||
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
||||
|
||||
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert [row["profile"] for row in rows] == ["browser-host.example:work"]
|
||||
|
||||
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
|
||||
"""An older remote (no advertised version) still triggers a clients.list query."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Compat shim framework.
|
||||
|
||||
The registries are empty today (no legacy-client shim has been needed since the
|
||||
first public release, 0.14.1), so every adapter must be a verbatim pass-through
|
||||
regardless of client version. These tests lock that in and exercise the
|
||||
empty-registry short-circuit so the seam can't silently start mutating traffic.
|
||||
"""
|
||||
import browser_cli.compat as compat
|
||||
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
|
||||
|
||||
def test_registries_are_empty():
|
||||
assert compat.commands._COMPAT == []
|
||||
assert compat.auth._AUTH_COMPAT == []
|
||||
|
||||
def test_adapt_auth_is_passthrough_for_any_version():
|
||||
msg = {"id": "1", "command": "tabs.list", "pubkey": "ABCdef", "args": {"x": 1}}
|
||||
for version in ("0.9.0", "0.14.1", "0.16.4", "99.0.0"):
|
||||
out = adapt_auth(msg, version)
|
||||
assert out == msg
|
||||
# pubkey casing is NOT normalized anymore (the old <0.9.3 shim is gone)
|
||||
assert out["pubkey"] == "ABCdef"
|
||||
|
||||
def test_adapt_request_is_passthrough():
|
||||
msg = {"command": "tabs.query", "args": {"search": "docs"}}
|
||||
assert adapt_request(msg, "0.9.0") == msg
|
||||
assert adapt_request(msg, "0.16.4") == msg
|
||||
|
||||
def test_adapt_response_is_passthrough():
|
||||
resp = b'{"id":"1","success":true,"data":[]}'
|
||||
assert adapt_response(resp, "tabs.list", "0.9.0") == resp
|
||||
assert adapt_response(resp, "tabs.list", "0.16.4") == resp
|
||||
|
||||
def test_empty_guard_skips_version_parsing(monkeypatch):
|
||||
"""With empty registries the adapters return before parse_version runs."""
|
||||
called = False
|
||||
|
||||
def _boom(_v):
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("parse_version should not be called on an empty registry")
|
||||
|
||||
monkeypatch.setattr(compat.auth, "parse_version", _boom)
|
||||
monkeypatch.setattr(compat.commands, "parse_version", _boom)
|
||||
|
||||
assert adapt_auth({"a": 1}, "0.9.0") == {"a": 1}
|
||||
assert adapt_request({"a": 1}, "0.9.0") == {"a": 1}
|
||||
assert adapt_response(b"x", "cmd", "0.9.0") == b"x"
|
||||
assert called is False
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge, verify_challenge_signature
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.remote import known_hosts
|
||||
|
||||
def _challenge(tmp_path):
|
||||
key = load_or_create_server_identity(tmp_path / "server.pem")
|
||||
msg = {
|
||||
"type": "challenge",
|
||||
"nonce": "00" * 32,
|
||||
"server_version": "0.16.4",
|
||||
"min_client_version": "0.9.0",
|
||||
"server_pubkey": public_key_hex(key),
|
||||
}
|
||||
msg["server_sig"] = sign_challenge(msg, key)
|
||||
return msg
|
||||
|
||||
def test_challenge_signature_verifies(tmp_path):
|
||||
challenge = _challenge(tmp_path)
|
||||
|
||||
assert verify_challenge_signature(challenge) is True
|
||||
|
||||
challenge["nonce"] = "11" * 32
|
||||
assert verify_challenge_signature(challenge) is False
|
||||
|
||||
def test_known_host_mismatch_is_rejected(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"browser-host.example": "00" * 32}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="REMOTE SERVER IDENTITY CHANGED"):
|
||||
known_hosts.verify_known_host("browser-host.example", challenge)
|
||||
|
||||
def test_unknown_non_interactive_host_is_rejected(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Unknown remote server identity"):
|
||||
known_hosts.verify_known_host("browser-host.example", challenge)
|
||||
|
||||
def test_loopback_unknown_host_is_allowed(monkeypatch, tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
challenge = _challenge(tmp_path)
|
||||
monkeypatch.setattr(known_hosts, "KNOWN_HOSTS_PATH", path)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
known_hosts.verify_known_host("127.0.0.1:8765", challenge)
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
def test_save_and_remove_known_host(tmp_path):
|
||||
path = tmp_path / "known_hosts.json"
|
||||
known_hosts.save_known_host("browser-host.example:443", "11" * 32, path)
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": "11" * 32}
|
||||
assert known_hosts.remove_known_host("browser-host.example", path) is True
|
||||
assert known_hosts.load_known_hosts(path) == {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import json
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from browser_cli.commands.remote import remote_group
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
def test_save_remote_persists_endpoint_without_key(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
|
||||
remote_registry.save_remote("browser-host.example:443")
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"browser-host.example": {}}
|
||||
|
||||
def test_save_remote_with_key_and_remove(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
|
||||
remote_registry.save_remote("browser-host.example", "agent")
|
||||
|
||||
assert remote_registry.load_remotes() == {"browser-host.example": {"key": "agent"}}
|
||||
assert remote_registry.remove_remote("browser-host.example:443") is True
|
||||
assert remote_registry.load_remotes() == {}
|
||||
|
||||
def test_remote_add_list_remove_cli(monkeypatch, tmp_path):
|
||||
path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr(remote_registry, "REMOTE_REGISTRY_PATH", path)
|
||||
runner = CliRunner()
|
||||
|
||||
add_result = runner.invoke(remote_group, ["add", "browser-host.example", "--key", "agent"])
|
||||
list_result = runner.invoke(remote_group, ["list"])
|
||||
remove_result = runner.invoke(remote_group, ["remove", "browser-host.example"])
|
||||
|
||||
assert add_result.exit_code == 0
|
||||
assert "Added remote browser-host.example with key agent" in add_result.output
|
||||
assert list_result.exit_code == 0
|
||||
assert "browser-host.example" in list_result.output
|
||||
assert "agent" in list_result.output
|
||||
assert remove_result.exit_code == 0
|
||||
assert "Removed browser-host.example" in remove_result.output
|
||||
assert remote_registry.load_remotes() == {}
|
||||
@@ -228,33 +228,6 @@ class TestAuthSuccess:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
||||
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair() # pub is lowercase hex
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
# old client sends uppercase pubkey
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
||||
msg["sig"] = sign(priv, nonce, msg).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_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.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
Reference in New Issue
Block a user