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
+57
View File
@@ -0,0 +1,57 @@
"""Persistent server identity keys for SSH-style remote host pinning."""
from __future__ import annotations
import json
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
from browser_cli.constants import CONFIG_DIR
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
"""Load the persistent serve identity key, creating it on first start."""
if path.exists():
key = load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"server identity key is not Ed25519: {path}")
return key
path.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
fd = path.open("xb")
try:
fd.write(pem)
finally:
fd.close()
path.chmod(0o600)
return key
def public_key_hex(key: Ed25519PrivateKey) -> str:
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def _signed_challenge_fields(challenge: dict) -> dict:
return {key: value for key, value in challenge.items() if key != "server_sig"}
def challenge_payload(challenge: dict) -> bytes:
"""Canonical bytes signed by the server identity key."""
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
return key.sign(challenge_payload(challenge)).hex()
def verify_challenge_signature(challenge: dict) -> bool:
pub_hex = challenge.get("server_pubkey")
sig_hex = challenge.get("server_sig")
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
return True
except (InvalidSignature, ValueError):
return False
+29
View File
@@ -243,6 +243,35 @@ def collect_browser_clients(
return rows
if remote:
targets = remote_browser_targets(remote, key=key)
if browser_alias:
targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
if targets:
uncached = []
for target in targets:
cached = _cached_client_row(target)
if cached is not None:
rows.append(cached)
else:
uncached.append(target)
results = _run_concurrent([
(lambda t=t: _client_rows_async(
t.display_name,
profile=t.profile,
remote=remote,
key=key,
profile_group=t.display_group,
))
for t in uncached
])
for result in results:
if isinstance(result, (BrowserNotConnected, RuntimeError)):
continue
if isinstance(result, BaseException):
raise result
rows.extend(result)
return rows
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for item in result or []:
row = dict(item)
+95 -20
View File
@@ -1,18 +1,53 @@
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
from browser_cli.errors import BrowserNotConnected
from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
console = Console()
def _print_remotes() -> None:
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
console.print(table)
def _remove_remote(endpoint: str, *, verb: str) -> None:
if not remove_remote(endpoint):
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
console.print(f"[green]{verb} {endpoint}[/green]")
def _fetch_server_pubkey(endpoint: str) -> str:
from browser_cli.auth.server_identity import verify_challenge_signature
from browser_cli.remote.auth import parse_challenge
from browser_cli.remote.socket import connect_socket, recv_all
sock = connect_socket(endpoint)
try:
challenge, _nonce = parse_challenge(recv_all(sock) or b"")
finally:
sock.close()
if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
raise BrowserNotConnected("remote server did not advertise a server identity key")
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("remote server identity signature is invalid")
return str(challenge["server_pubkey"])
@click.group("remote")
def remote_group():
"""Manage remembered browser-cli remote endpoints."""
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
browser_header="Profile",
)
@remote_group.command("add")
@click.argument("endpoint")
@click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
def remote_add(endpoint, key_spec):
"""Remember a remote endpoint for global multi-browser commands."""
save_remote(endpoint, key_spec)
if key_spec:
console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
else:
console.print(f"[green]Added remote {endpoint}[/green]")
@remote_group.command("trust")
@click.argument("endpoint")
@click.argument("key_spec")
@@ -50,29 +96,58 @@ def remote_trust(endpoint, key_spec):
save_remote_key(endpoint, key_spec)
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
remotes = load_remotes()
if not remotes:
console.print("[yellow]No remembered remotes[/yellow]")
@remote_group.command("list")
def remote_list():
"""List remembered remote endpoints."""
_print_remotes()
@remote_group.command("trust-host")
@click.argument("endpoint")
@click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
@handle_errors
def remote_trust_host(endpoint, pubkey):
"""Pin a remote server identity key, SSH known_hosts style."""
server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
save_known_host(endpoint, server_pubkey)
console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
@remote_group.command("known-hosts")
def remote_known_hosts():
"""List pinned remote server identity keys."""
known = load_known_hosts()
if not known:
console.print("[yellow]No known remote server identities[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Endpoint")
table.add_column("Key")
for endpoint, cfg in sorted(remotes.items()):
table.add_row(endpoint, str(cfg.get("key", "")))
table.add_column("Fingerprint")
table.add_column("Public Key")
for endpoint, pubkey in sorted(known.items()):
table.add_row(endpoint, fingerprint(pubkey), pubkey)
console.print(table)
@remote_group.command("untrust-host")
@click.argument("endpoint")
def remote_untrust_host(endpoint):
"""Remove a pinned remote server identity key."""
if not remove_known_host(endpoint):
console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
return
console.print(f"[green]Removed server identity for {endpoint}[/green]")
@remote_group.command("keys")
def remote_keys():
"""List remembered remote key specs."""
_print_remotes()
@remote_group.command("remove")
@click.argument("endpoint")
def remote_remove(endpoint):
"""Remove a remembered remote endpoint."""
_remove_remote(endpoint, verb="Removed")
@remote_group.command("revoke")
@click.argument("endpoint")
def remote_revoke(endpoint):
"""Remove remembered key/config for ENDPOINT."""
remotes = load_remotes()
if endpoint not in remotes:
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
return
del remotes[endpoint]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
console.print(f"[green]Revoked {endpoint}[/green]")
_remove_remote(endpoint, verb="Revoked")
+13 -33
View File
@@ -3,48 +3,28 @@ Auth-field normalizers — applied to the raw incoming message *before* the
auth check runs. Protocol fields (pubkey, sig, …) are still present here.
Add one entry per breaking auth-field change:
("X.Y.Z", transformer_fn)
("X.Y.Z", transformer_fn)
Entries must stay in ascending version order.
The registry is intentionally empty: the first public release was 0.14.1, so no
legacy-client shim has ever been needed. This module is the seam — add a tuple
here (and a unit test for it) the day a breaking auth-field change ships.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
def _auth_0_9_3(msg: dict) -> dict:
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
changed: dict = {}
pk = msg.get("pubkey")
if isinstance(pk, str) and pk:
changed["pubkey"] = pk.lower()
if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
identifier = args.get("identifier")
patched = dict(args)
if isinstance(trust_pk, str) and trust_pk:
patched["pubkey"] = trust_pk.lower()
if isinstance(identifier, str) and identifier and len(identifier) == 64:
patched["identifier"] = identifier.lower()
if patched != args:
changed["args"] = patched
return {**msg, **changed} if changed else msg
# ── registry ──────────────────────────────────────────────────────────────────
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
("0.9.3", _auth_0_9_3),
]
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
def adapt_auth(msg: dict, client_version: str) -> dict:
"""Apply all auth normalizers needed to bring msg up to the current format."""
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
"""Apply all auth normalizers needed to bring msg up to the current format."""
if not _AUTH_COMPAT:
return msg
cv = parse_version(client_version)
for version, fn in _AUTH_COMPAT:
if cv < parse_version(version):
msg = fn(msg)
return msg
+19 -16
View File
@@ -3,7 +3,7 @@ Command-format shims — applied to clean_msg (protocol fields already stripped)
before forwarding to the native host, and to responses before sending back.
Add one entry per breaking command-format change:
("X.Y.Z", request_fn, response_fn)
("X.Y.Z", request_fn, response_fn)
- request_fn(msg: dict) -> dict or None
- response_fn(resp: bytes, command: str) -> bytes or None
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
Entries must stay in ascending version order.
adapt_request walks forward (oldest first); adapt_response walks backward.
Current baseline: 0.9.3 — no command-format shims needed yet.
The registry is intentionally empty: no command-format shim has been needed
since the first public release (0.14.1). This module is the seam — add a tuple
here (and a unit test for it) the day a breaking command-format change ships.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
# ── registry ──────────────────────────────────────────────────────────────────
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
]
def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current browser command format."""
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
"""Upgrade a client message to the current browser command format."""
if not _COMPAT:
return msg
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a native-host response to the format the client expects."""
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
"""Downgrade a native-host response to the format the client expects."""
if not _COMPAT:
return resp
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+108
View File
@@ -0,0 +1,108 @@
"""SSH-style known-hosts pinning for browser-cli remote servers."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from browser_cli.constants import CONFIG_DIR
from browser_cli.endpoints import _normalize_endpoint
from browser_cli.errors import BrowserNotConnected
KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
def fingerprint(pubkey_hex: str) -> str:
"""Return a compact SHA256 fingerprint for display."""
import base64
import hashlib
digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def load_known_hosts(path: Path | None = None) -> dict[str, str]:
path = path or KNOWN_HOSTS_PATH
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
known[_normalize_endpoint(endpoint)] = pubkey_hex
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
path = path or KNOWN_HOSTS_PATH
known = load_known_hosts(path)
normalized = _normalize_endpoint(endpoint)
if normalized not in known:
return False
del known[normalized]
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as file:
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
return True
def _is_loopback_endpoint(endpoint: str) -> bool:
host, sep, _port = endpoint.rpartition(":")
check = host if sep else endpoint
return check in {"127.0.0.1", "localhost", "::1"}
def verify_known_host(endpoint: str, challenge: dict | None) -> None:
"""Verify and pin the server identity from a challenge frame.
First contact auto-adds the host when the process is interactive, mirroring
SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
`browser-cli remote trust-host ENDPOINT`.
"""
if not isinstance(challenge, dict):
return
pubkey = challenge.get("server_pubkey")
if not isinstance(pubkey, str) or not pubkey:
return
from browser_cli.auth.server_identity import verify_challenge_signature
if not verify_challenge_signature(challenge):
raise BrowserNotConnected("Remote server identity signature is invalid")
normalized = _normalize_endpoint(endpoint)
known = load_known_hosts()
expected = known.get(normalized)
if expected is None and _is_loopback_endpoint(endpoint):
return
if expected is None:
if not sys.stdin.isatty():
raise BrowserNotConnected(
f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
f"Run: browser-cli remote trust-host {normalized}"
)
sys.stderr.write(
f"The authenticity of remote '{normalized}' can't be established.\n"
f"Server key fingerprint is {fingerprint(pubkey)}.\n"
"Trust this server and add it to known hosts? [y/N] "
)
answer = sys.stdin.readline().strip().lower()
if answer not in {"y", "yes"}:
raise BrowserNotConnected("Remote server identity was not trusted")
save_known_host(normalized, pubkey)
sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
return
if expected != pubkey:
raise BrowserNotConnected(
f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
f"Known: {fingerprint(expected)}\n"
f"Seen: {fingerprint(pubkey)}\n"
f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
)
+28 -7
View File
@@ -28,18 +28,39 @@ def is_valid_key_spec(value: str) -> bool:
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
)
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
def save_remote(endpoint: str, key_spec: str | None = None) -> None:
"""Persist a remote endpoint, optionally with a key spec."""
if not endpoint:
return
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
remotes[endpoint] = current
current = remotes.get(normalized, {})
if key_spec:
if not is_valid_key_spec(key_spec):
return
current["key"] = key_spec
remotes[normalized] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
def remove_remote(endpoint: str) -> bool:
"""Remove a remembered remote endpoint. Returns True when it existed."""
normalized = _normalize_endpoint(endpoint)
remotes = load_remotes()
if normalized not in remotes:
return False
del remotes[normalized]
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
return True
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
save_remote(endpoint, key_spec)
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
+7 -1
View File
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
split_endpoint as _split_endpoint,
)
from browser_cli.remote import pool as _pool
from browser_cli.remote.known_hosts import verify_known_host
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
# Reuse an already-authenticated connection when one is idle for this endpoint.
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
sock = _connect_socket(endpoint)
try:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
challenge_raw = _recv_all(sock)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
except BaseException:
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
reader, writer = await _open_async_connection(endpoint)
try:
challenge_raw = await _async_recv_all(reader)
challenge, _nonce_hex = _parse_challenge(challenge_raw)
verify_known_host(endpoint, challenge)
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
+4
View File
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
server_key = await asyncio.to_thread(load_or_create_server_identity)
challenge_msg["server_pubkey"] = public_key_hex(server_key)
challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
return nonce, pq_private_key, challenge_msg