feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Security: - serve.py: server now sends nonce challenge before accepting any command; clients sign nonce + SHA256(canonical_payload) with Ed25519 key - New --authorized-keys FILE option for serve; token auth still works as fallback - Connection limit: BoundedSemaphore(64) in serve.py - Secure file creation with os.open(..., 0o600) for token/key files - New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python), sign/verify helpers compatible with both file keys and agent-held keys (YubiKey, TPM, gpg-agent) Features: - YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK - New `browser-cli auth` command group: keygen, trust, show, keys - Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key; pass "agent" or "agent:<selector>" to use SSH agent key - BrowserCLI Python API gains key= parameter Bug fixes (11 issues across two review passes): - client.py: check response is not None before json.loads - native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows Listener leak on accept error - __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None - extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback - extension/session.ts: removeListener before addListener prevents duplicate handlers Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
-19
@@ -18,6 +18,7 @@ Usage:
|
||||
"""
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||
from browser_cli.models import Group, Tab
|
||||
@@ -33,7 +34,7 @@ class BrowserCounts:
|
||||
|
||||
|
||||
class BrowserCLI:
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None):
|
||||
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None, key: str | None = None):
|
||||
"""
|
||||
Args:
|
||||
browser: Profile alias to target. Required when multiple browser
|
||||
@@ -42,14 +43,18 @@ class BrowserCLI:
|
||||
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
|
||||
Can be combined with ``browser`` to route to a specific
|
||||
remote profile.
|
||||
token: Auth token for the remote serve instance.
|
||||
token: Auth token for the remote serve instance (legacy token auth).
|
||||
key: Path to Ed25519 private key PEM for pubkey auth. When set,
|
||||
overrides token auth. Defaults to ``~/.config/browser-cli/client.key.pem``
|
||||
if that file exists.
|
||||
"""
|
||||
self._browser = browser
|
||||
self._remote = remote
|
||||
self._token = token
|
||||
self._key = Path(key) if key else None
|
||||
|
||||
def _cmd(self, command: str, args: dict | None = None):
|
||||
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token)
|
||||
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token, key=self._key)
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
@@ -64,10 +69,11 @@ class BrowserCLI:
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
for target in self._multi_browser_targets():
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token)
|
||||
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token, key=self._key)
|
||||
else:
|
||||
data = send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
@@ -75,7 +81,7 @@ class BrowserCLI:
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
if self._multi_browser_targets():
|
||||
if targets:
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
@@ -173,7 +179,9 @@ class BrowserCLI:
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": background, "window": window, "group": group,
|
||||
})
|
||||
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("navigate.open_wait returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def wait_for_load(
|
||||
self,
|
||||
@@ -291,7 +299,9 @@ class BrowserCLI:
|
||||
) -> "Tab":
|
||||
"""Block until the tab URL matches regex pattern. Returns the Tab."""
|
||||
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
|
||||
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError("tabs.watch_url returned unexpected data")
|
||||
return self._make_tab(data)
|
||||
|
||||
def tabs_screenshot(
|
||||
self,
|
||||
@@ -440,7 +450,7 @@ class BrowserCLI:
|
||||
for target, windows in multi_results
|
||||
for window in (windows or [])
|
||||
]
|
||||
return self._cmd("windows.list", {})
|
||||
return self._cmd("windows.list", {}) or []
|
||||
|
||||
def windows_rename(self, window_id: int, name: str) -> None:
|
||||
self._cmd("windows.rename", {"windowId": window_id, "name": name})
|
||||
@@ -450,12 +460,12 @@ class BrowserCLI:
|
||||
|
||||
def windows_open(self, url: str | None = None) -> dict:
|
||||
"""Open a new browser window, optionally on a URL."""
|
||||
return self._cmd("windows.open", {"url": url})
|
||||
return self._cmd("windows.open", {"url": url}) or {}
|
||||
|
||||
# ── DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
def dom_query(self, selector: str) -> list[dict]:
|
||||
return self._cmd("dom.query", {"selector": selector})
|
||||
return self._cmd("dom.query", {"selector": selector}) or []
|
||||
|
||||
def dom_click(self, selector: str) -> None:
|
||||
self._cmd("dom.click", {"selector": selector})
|
||||
@@ -464,13 +474,13 @@ class BrowserCLI:
|
||||
self._cmd("dom.type", {"selector": selector, "text": text})
|
||||
|
||||
def dom_attr(self, selector: str, attr: str) -> list[str]:
|
||||
return self._cmd("dom.attr", {"selector": selector, "attr": attr})
|
||||
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
|
||||
|
||||
def dom_text(self, selector: str) -> list[str]:
|
||||
return self._cmd("dom.text", {"selector": selector})
|
||||
return self._cmd("dom.text", {"selector": selector}) or []
|
||||
|
||||
def dom_exists(self, selector: str) -> bool:
|
||||
return self._cmd("dom.exists", {"selector": selector})
|
||||
return self._cmd("dom.exists", {"selector": selector}) or False
|
||||
|
||||
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
|
||||
"""Scroll to a CSS selector or to pixel coordinates."""
|
||||
@@ -630,13 +640,13 @@ class BrowserCLI:
|
||||
# ── Extract ───────────────────────────────────────────────────────────
|
||||
|
||||
def extract_links(self) -> list[dict]:
|
||||
return self._cmd("extract.links", {})
|
||||
return self._cmd("extract.links", {}) or []
|
||||
|
||||
def extract_images(self) -> list[dict]:
|
||||
return self._cmd("extract.images", {})
|
||||
return self._cmd("extract.images", {}) or []
|
||||
|
||||
def extract_text(self) -> str:
|
||||
return self._cmd("extract.text", {})
|
||||
return self._cmd("extract.text", {}) or ""
|
||||
|
||||
def extract_json(self, selector: str):
|
||||
return self._cmd("extract.json", {"selector": selector})
|
||||
@@ -653,7 +663,7 @@ class BrowserCLI:
|
||||
self._cmd("session.load", {"name": name})
|
||||
|
||||
def session_diff(self, name_a: str, name_b: str) -> dict:
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b})
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
def session_list(self) -> list[dict]:
|
||||
"""Return saved sessions.
|
||||
@@ -667,7 +677,7 @@ class BrowserCLI:
|
||||
for target, sessions in multi_results
|
||||
for session in (sessions or [])
|
||||
]
|
||||
return self._cmd("session.list", {})
|
||||
return self._cmd("session.list", {}) or []
|
||||
|
||||
def session_remove(self, name: str) -> None:
|
||||
self._cmd("session.remove", {"name": name})
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Ed25519 keypair management and challenge-response auth helpers."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
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,
|
||||
)
|
||||
|
||||
_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli"
|
||||
DEFAULT_KEY_PATH = _CONFIG_DIR / "client.key.pem"
|
||||
DEFAULT_AUTHORIZED_KEYS_PATH = _CONFIG_DIR / "authorized_keys"
|
||||
|
||||
# ── SSH agent protocol constants ───────────────────────────────────────────────
|
||||
_SSH_AGENTC_REQUEST_IDENTITIES = 11
|
||||
_SSH_AGENT_IDENTITIES_ANSWER = 12
|
||||
_SSH_AGENTC_SIGN_REQUEST = 13
|
||||
_SSH_AGENT_SIGN_RESPONSE = 14
|
||||
|
||||
|
||||
def _pack_str(s: bytes) -> bytes:
|
||||
return struct.pack(">I", len(s)) + s
|
||||
|
||||
|
||||
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
|
||||
n = struct.unpack_from(">I", data, off)[0]
|
||||
return data[off + 4 : off + 4 + n], off + 4 + n
|
||||
|
||||
|
||||
def _agent_roundtrip(msg: bytes) -> bytes:
|
||||
sock_path = os.environ.get("SSH_AUTH_SOCK")
|
||||
if not sock_path:
|
||||
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(10)
|
||||
sock.connect(sock_path)
|
||||
sock.sendall(struct.pack(">I", len(msg)) + msg)
|
||||
raw_len = b""
|
||||
while len(raw_len) < 4:
|
||||
chunk = sock.recv(4 - len(raw_len))
|
||||
if not chunk:
|
||||
raise RuntimeError("SSH agent closed connection")
|
||||
raw_len += chunk
|
||||
n = struct.unpack(">I", raw_len)[0]
|
||||
resp = b""
|
||||
while len(resp) < n:
|
||||
chunk = sock.recv(n - len(resp))
|
||||
if not chunk:
|
||||
raise RuntimeError("SSH agent closed connection mid-response")
|
||||
resp += chunk
|
||||
return resp
|
||||
|
||||
|
||||
# ── AgentKey ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AgentKey:
|
||||
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
|
||||
blob: bytes
|
||||
comment: str
|
||||
|
||||
@property
|
||||
def pubkey_bytes(self) -> bytes:
|
||||
_algo, off = _unpack_str(self.blob, 0)
|
||||
key_bytes, _ = _unpack_str(self.blob, off)
|
||||
return key_bytes
|
||||
|
||||
|
||||
# ── Agent helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def agent_list_keys() -> list[AgentKey]:
|
||||
"""Return all Ed25519 keys currently held by the SSH agent."""
|
||||
resp = _agent_roundtrip(bytes([_SSH_AGENTC_REQUEST_IDENTITIES]))
|
||||
if resp[0] != _SSH_AGENT_IDENTITIES_ANSWER:
|
||||
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
|
||||
n_keys = struct.unpack_from(">I", resp, 1)[0]
|
||||
keys: list[AgentKey] = []
|
||||
off = 5
|
||||
for _ in range(n_keys):
|
||||
blob, off = _unpack_str(resp, off)
|
||||
comment, off = _unpack_str(resp, off)
|
||||
algo, _ = _unpack_str(blob, 0)
|
||||
if algo == b"ssh-ed25519":
|
||||
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
|
||||
return keys
|
||||
|
||||
|
||||
def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
||||
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
|
||||
try:
|
||||
keys = agent_list_keys()
|
||||
except Exception:
|
||||
return None
|
||||
for key in keys:
|
||||
if selector is None or selector in key.comment:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
|
||||
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
|
||||
msg = (
|
||||
bytes([_SSH_AGENTC_SIGN_REQUEST])
|
||||
+ _pack_str(key.blob)
|
||||
+ _pack_str(data)
|
||||
+ struct.pack(">I", 0)
|
||||
)
|
||||
resp = _agent_roundtrip(msg)
|
||||
if resp[0] != _SSH_AGENT_SIGN_RESPONSE:
|
||||
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
|
||||
sig_blob, _ = _unpack_str(resp, 1)
|
||||
_algo, soff = _unpack_str(sig_blob, 0)
|
||||
raw_sig, _ = _unpack_str(sig_blob, soff)
|
||||
if len(raw_sig) != 64:
|
||||
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
||||
return raw_sig
|
||||
|
||||
|
||||
# ── File-based key helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def generate_keypair() -> tuple[bytes, str]:
|
||||
"""Return (private_key_pem_bytes, public_key_hex)."""
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
return pem, pub_hex
|
||||
|
||||
|
||||
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
||||
return load_pem_private_key(path.read_bytes(), password=None)
|
||||
|
||||
|
||||
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||
if isinstance(key, AgentKey):
|
||||
return key.pubkey_bytes.hex()
|
||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
|
||||
|
||||
# ── Canonical payload + sign/verify ───────────────────────────────────────────
|
||||
|
||||
def canonical_payload(msg: dict) -> bytes:
|
||||
"""Deterministic JSON encoding of msg without auth fields."""
|
||||
return json.dumps(
|
||||
{k: v for k, v in msg.items() if k not in {"pubkey", "sig"}},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict) -> bytes:
|
||||
"""Sign nonce + SHA256(canonical_payload(msg)) — works for both file keys and agent keys."""
|
||||
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||
if isinstance(key, AgentKey):
|
||||
return agent_sign_raw(key, data)
|
||||
return key.sign(data)
|
||||
|
||||
|
||||
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str) -> bool:
|
||||
"""Return True if sig_hex is a valid Ed25519 signature over the canonical payload."""
|
||||
try:
|
||||
pub_bytes = bytes.fromhex(pub_hex)
|
||||
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
||||
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||
pub_key.verify(bytes.fromhex(sig_hex), message)
|
||||
return True
|
||||
except (InvalidSignature, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.startswith("#")
|
||||
]
|
||||
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str) -> bool:
|
||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = set(load_authorized_keys(path))
|
||||
if pub_hex in existing:
|
||||
return False
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(pub_hex + "\n")
|
||||
return True
|
||||
+144
-11
@@ -101,12 +101,7 @@ def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
|
||||
target_profile = _rename_target_profile(target_browser)
|
||||
|
||||
profiles: dict[str, str] = {}
|
||||
if REGISTRY_PATH.exists():
|
||||
try:
|
||||
profiles = json.loads(REGISTRY_PATH.read_text())
|
||||
except Exception:
|
||||
profiles = {}
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
|
||||
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
@@ -200,24 +195,161 @@ def _print_version(ctx, param, value):
|
||||
"--token", default=None, metavar="TOKEN",
|
||||
help="Auth token for the remote browser-cli serve instance.",
|
||||
)
|
||||
@click.option(
|
||||
"--key", default=None, metavar="PATH",
|
||||
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx, browser, remote, token):
|
||||
def main(ctx, browser, remote, token, key):
|
||||
"""Control your running browser from the terminal via a Chrome extension."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["browser"] = browser
|
||||
ctx.obj["browser_explicit"] = browser is not None
|
||||
if browser:
|
||||
os.environ["BROWSER_CLI_PROFILE"] = browser
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
|
||||
ctx.obj["remote"] = remote
|
||||
ctx.obj["token"] = token
|
||||
ctx.obj["key"] = key
|
||||
if remote:
|
||||
os.environ["BROWSER_CLI_REMOTE"] = remote
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
|
||||
if token:
|
||||
save_remote_token(remote, token)
|
||||
if token:
|
||||
os.environ["BROWSER_CLI_TOKEN"] = token
|
||||
if key:
|
||||
os.environ["BROWSER_CLI_KEY"] = key
|
||||
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@click.group("auth")
|
||||
def auth_group():
|
||||
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
|
||||
|
||||
|
||||
@auth_group.command("keygen")
|
||||
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
|
||||
@click.option("--force", is_flag=True, help="Overwrite existing key.")
|
||||
def cmd_auth_keygen(output, force):
|
||||
"""Generate an Ed25519 keypair for pubkey auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
|
||||
|
||||
key_path = Path(output) if output else DEFAULT_KEY_PATH
|
||||
if key_path.exists() and not force:
|
||||
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
|
||||
sys.exit(1)
|
||||
pem, pub_hex = generate_keypair()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(pem)
|
||||
console.print(f"[green]✓[/green] Private key: {key_path}")
|
||||
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
|
||||
console.print(f"\nOn the serve host, trust this key:")
|
||||
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
|
||||
|
||||
|
||||
@auth_group.command("trust")
|
||||
@click.argument("pubkey")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
def cmd_auth_trust(pubkey, keys_file):
|
||||
"""Add a public key to the authorized keys file on the serve host."""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||
|
||||
if len(pubkey) != 64:
|
||||
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
|
||||
sys.exit(1)
|
||||
try:
|
||||
bytes.fromhex(pubkey)
|
||||
except ValueError:
|
||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||
sys.exit(1)
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
added = add_authorized_key(path, pubkey)
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f" File: {path}")
|
||||
console.print(f"\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||
|
||||
|
||||
@auth_group.command("show")
|
||||
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
|
||||
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
|
||||
def cmd_auth_show(key_src):
|
||||
"""Print the Ed25519 public key that browser-cli will use for auth."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
|
||||
|
||||
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
|
||||
|
||||
if src == "agent" or src.startswith("agent:"):
|
||||
selector = src[6:] or None
|
||||
key = agent_find_key(selector)
|
||||
if key is None:
|
||||
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
|
||||
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
|
||||
sys.exit(1)
|
||||
console.print(f"[dim]source:[/dim] agent ({key.comment})")
|
||||
console.print(public_key_hex(key))
|
||||
return
|
||||
|
||||
path = Path(src)
|
||||
if not path.exists():
|
||||
console.print(f"[red]No key found at {path}[/red]")
|
||||
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
|
||||
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
|
||||
sys.exit(1)
|
||||
try:
|
||||
priv = load_private_key(path)
|
||||
console.print(f"[dim]source:[/dim] {path}")
|
||||
console.print(public_key_hex(priv))
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to load key:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@auth_group.command("keys")
|
||||
def cmd_auth_keys():
|
||||
"""List all Ed25519 keys available for pubkey auth (file + SSH agent)."""
|
||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_list_keys, load_private_key, public_key_hex
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Source")
|
||||
table.add_column("Comment / Path")
|
||||
table.add_column("Public Key")
|
||||
|
||||
# File key
|
||||
if DEFAULT_KEY_PATH.exists():
|
||||
try:
|
||||
priv = load_private_key(DEFAULT_KEY_PATH)
|
||||
hex_key = public_key_hex(priv)
|
||||
table.add_row("[green]file[/green]", str(DEFAULT_KEY_PATH), hex_key)
|
||||
except Exception as e:
|
||||
table.add_row("[red]file[/red]", str(DEFAULT_KEY_PATH), f"[red]{e}[/red]")
|
||||
|
||||
# Agent keys
|
||||
try:
|
||||
agent_keys = agent_list_keys()
|
||||
for k in agent_keys:
|
||||
table.add_row("[cyan]agent[/cyan]", k.comment, public_key_hex(k))
|
||||
except Exception as e:
|
||||
table.add_row("[dim]agent[/dim]", f"[dim]{e}[/dim]", "")
|
||||
|
||||
if table.row_count == 0:
|
||||
console.print("[yellow]No keys found.[/yellow] Run: [dim]browser-cli auth keygen[/dim]")
|
||||
return
|
||||
console.print(table)
|
||||
console.print("\nTo trust a key on the serve host:")
|
||||
console.print(" [dim]browser-cli auth trust <public-key>[/dim]")
|
||||
|
||||
|
||||
main.add_command(auth_group)
|
||||
|
||||
# ── Sub-command groups ─────────────────────────────────────────────────────────
|
||||
main.add_command(nav_group)
|
||||
main.add_command(tabs_group)
|
||||
@@ -247,6 +379,7 @@ def clients_group(ctx):
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
token = (ctx.obj or {}).get("token") or os.environ.get("BROWSER_CLI_TOKEN")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
# --browser <host> without --remote: resolve host alias to a remote endpoint,
|
||||
@@ -261,7 +394,7 @@ def clients_group(ctx):
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token)
|
||||
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = target.display_name
|
||||
all_clients.append(c)
|
||||
@@ -269,7 +402,7 @@ def clients_group(ctx):
|
||||
continue
|
||||
elif remote:
|
||||
try:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token)
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = c.get("profile") or browser_alias or "remote"
|
||||
all_clients.append(c)
|
||||
|
||||
+63
-9
@@ -23,6 +23,7 @@ from browser_cli.registry import load_registry
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
|
||||
|
||||
|
||||
class BrowserNotConnected(Exception):
|
||||
@@ -72,11 +73,9 @@ def save_remote_token(endpoint: str, token: str | None) -> None:
|
||||
current["token"] = token
|
||||
remotes[endpoint] = current
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True), encoding="utf-8")
|
||||
try:
|
||||
REMOTE_REGISTRY_PATH.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
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))
|
||||
|
||||
|
||||
def token_for_remote(endpoint: str | None) -> str | None:
|
||||
@@ -193,12 +192,61 @@ def _resolve_socket(profile: str | None = None) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _send_remote(endpoint: str, framed: bytes) -> bytes:
|
||||
def _load_private_key(key_path: "Path | str | None" = None):
|
||||
"""Load an Ed25519 signing key.
|
||||
|
||||
Sources (in priority order):
|
||||
1. Explicit key_path / --key flag
|
||||
2. BROWSER_CLI_KEY environment variable
|
||||
3. Default PEM file (~/.config/browser-cli/client.key.pem)
|
||||
|
||||
Pass "agent" or "agent:<selector>" to use a key from the SSH agent
|
||||
(works with YubiKey via gpg-agent, TPM, or regular ssh-agent).
|
||||
"""
|
||||
raw = str(key_path) if key_path is not None else os.environ.get("BROWSER_CLI_KEY", str(_DEFAULT_KEY_PATH))
|
||||
|
||||
if raw == "agent" or raw.startswith("agent:"):
|
||||
selector = raw[6:] or None # "agent:cardno:..." → "cardno:..."
|
||||
from browser_cli.auth import agent_find_key
|
||||
return agent_find_key(selector)
|
||||
|
||||
path = Path(raw)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
from browser_cli.auth import load_private_key
|
||||
return load_private_key(path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
host, _, port_str = endpoint.rpartition(":")
|
||||
if not host or not port_str:
|
||||
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(30)
|
||||
sock.connect((host, int(port_str)))
|
||||
|
||||
# receive challenge
|
||||
challenge_raw = _recv_all(sock)
|
||||
if challenge_raw is None:
|
||||
raise BrowserNotConnected(f"No challenge received from {endpoint}")
|
||||
try:
|
||||
challenge = json.loads(challenge_raw)
|
||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig"}}
|
||||
sig = sign(private_key, nonce, clean_msg)
|
||||
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
|
||||
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
sock.sendall(framed)
|
||||
return _recv_all(sock)
|
||||
|
||||
@@ -217,7 +265,7 @@ def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any:
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None, key: "Path | None" = None) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
@@ -235,19 +283,23 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
"args": args or {},
|
||||
}
|
||||
if remote_endpoint:
|
||||
if resolved_token:
|
||||
private_key = _load_private_key(key)
|
||||
# use token auth only when no Ed25519 key is available
|
||||
if private_key is None and resolved_token:
|
||||
msg["token"] = resolved_token
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command != "browser-cli.targets":
|
||||
route_profile = _auto_route_remote(remote_endpoint, resolved_token)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
else:
|
||||
private_key = None
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
|
||||
try:
|
||||
if remote_endpoint:
|
||||
response = _send_remote(remote_endpoint, framed)
|
||||
response = _send_remote(remote_endpoint, msg, private_key)
|
||||
elif is_windows():
|
||||
sock_path = _resolve_socket(profile)
|
||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||
@@ -275,6 +327,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
|
||||
)
|
||||
|
||||
if response is None:
|
||||
raise ConnectionError("Connection closed before full response received")
|
||||
result = json.loads(response)
|
||||
if not result.get("success", True):
|
||||
raise RuntimeError(result.get("error", "unknown error from browser"))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import hmac, threading, secrets, socket, struct, click, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
_CONN_LIMIT = threading.BoundedSemaphore(64)
|
||||
from rich.console import Console
|
||||
from datetime import datetime
|
||||
|
||||
@@ -22,7 +25,7 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None) -> None:
|
||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, nonce:str) -> None:
|
||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
@@ -50,7 +53,24 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
msg_id = msg.get("id")
|
||||
command = msg.get("command", "?")
|
||||
|
||||
if server_token is not None:
|
||||
# ── auth ──────────────────────────────────────────────────────────────────
|
||||
if auth_keys is not None:
|
||||
pub = msg.get("pubkey") or ""
|
||||
sig = msg.get("sig") or ""
|
||||
if not pub or not sig:
|
||||
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
|
||||
_log(addr, command, None, "DENIED", "missing pubkey/sig")
|
||||
return
|
||||
if pub not in auth_keys:
|
||||
_send_error(msg_id, "unauthorized: untrusted public key")
|
||||
_log(addr, command, None, "DENIED", "untrusted key")
|
||||
return
|
||||
from browser_cli.auth import verify
|
||||
if not verify(pub, bytes.fromhex(nonce), msg, sig):
|
||||
_send_error(msg_id, "unauthorized: invalid signature")
|
||||
_log(addr, command, None, "DENIED", "bad signature")
|
||||
return
|
||||
elif server_token is not None:
|
||||
if not hmac.compare_digest(msg.get("token") or "", server_token):
|
||||
_send_error(msg_id, "unauthorized: invalid or missing token")
|
||||
_log(addr, command, None, "DENIED", "bad token")
|
||||
@@ -69,7 +89,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
|
||||
resolved_profile = msg.get("_route") or profile
|
||||
|
||||
strip = {"token", "_route"}
|
||||
strip = {"token", "_route", "pubkey", "sig"}
|
||||
if strip & msg.keys():
|
||||
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
|
||||
clean_header = struct.pack("<I", len(clean_payload))
|
||||
@@ -105,31 +125,54 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
||||
_log(addr, command, resolved_profile, "OK")
|
||||
else:
|
||||
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
except OSError as e:
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_send_error(msg_id, str(e))
|
||||
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None) -> None:
|
||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None) -> None:
|
||||
if not _CONN_LIMIT.acquire(blocking=False):
|
||||
client_sock.close()
|
||||
return
|
||||
client_sock.settimeout(30)
|
||||
with client_sock:
|
||||
_proxy_request(client_sock, addr, profile, server_token)
|
||||
try:
|
||||
with client_sock:
|
||||
nonce = secrets.token_hex(32)
|
||||
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
|
||||
try:
|
||||
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
|
||||
except OSError:
|
||||
return
|
||||
_proxy_request(client_sock, addr, profile, server_token, auth_keys, nonce)
|
||||
finally:
|
||||
_CONN_LIMIT.release()
|
||||
|
||||
@click.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||
@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable token authentication.")
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
|
||||
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Enables pubkey auth.")
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, token, no_auth):
|
||||
def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
|
||||
|
||||
if no_auth:
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
path = Path(auth_keys_file)
|
||||
auth_keys = load_authorized_keys(path)
|
||||
if not auth_keys:
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {path}")
|
||||
server_token = None
|
||||
elif no_auth:
|
||||
auth_keys = None
|
||||
server_token = None
|
||||
else:
|
||||
auth_keys = None
|
||||
server_token = token or secrets.token_urlsafe(32)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
@@ -137,6 +180,7 @@ def cmd_serve(ctx, host, port, token, no_auth):
|
||||
try:
|
||||
server.bind((host, port))
|
||||
except OSError as e:
|
||||
server.close()
|
||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||
sys.exit(1)
|
||||
server.listen(16)
|
||||
@@ -144,12 +188,16 @@ def cmd_serve(ctx, host, port, token, no_auth):
|
||||
browser_hint = f" (browser: {profile})" if profile else ""
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
|
||||
|
||||
if server_token:
|
||||
if auth_keys is not None:
|
||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({len(auth_keys)} trusted key{'s' if len(auth_keys) != 1 else ''})")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
elif server_token:
|
||||
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
|
||||
else:
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
@@ -158,7 +206,7 @@ def cmd_serve(ctx, host, port, token, no_auth):
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token), daemon=True).start()
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
finally:
|
||||
|
||||
@@ -47,13 +47,23 @@ PAGEABLE_COMMANDS = {
|
||||
|
||||
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
|
||||
|
||||
def _read_exact_stream(stream, n: int) -> bytes | None:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = stream.read(n - len(buf))
|
||||
if not chunk:
|
||||
return None # real EOF
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def read_native_message(stream) -> dict | None:
|
||||
raw_len = stream.read(4)
|
||||
if len(raw_len) < 4:
|
||||
raw_len = _read_exact_stream(stream, 4)
|
||||
if raw_len is None:
|
||||
return None
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = stream.read(msg_len)
|
||||
if len(data) < msg_len:
|
||||
data = _read_exact_stream(stream, msg_len)
|
||||
if data is None:
|
||||
return None
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
@@ -125,10 +135,16 @@ def stdin_reader(alias: str):
|
||||
def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
|
||||
if is_windows():
|
||||
while True:
|
||||
listener = None
|
||||
try:
|
||||
listener = Listener(sock_path, family="AF_PIPE")
|
||||
conn = listener.accept()
|
||||
except OSError:
|
||||
if listener is not None:
|
||||
try:
|
||||
listener.close()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user