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:
@@ -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,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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user