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