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

- 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:
2026-06-26 08:53:21 +02:00
parent 1ae9c33f00
commit 6270d8c956
28 changed files with 981 additions and 297 deletions
+49
View File
@@ -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
+48
View File
@@ -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
+64
View File
@@ -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) == {}
+42
View File
@@ -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() == {}
-27
View File
@@ -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)