feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Testing / test (push) Successful in 26s
Package Extension / package-extension (push) Successful in 22s
Build & Publish Package / publish (push) Successful in 27s

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:
2026-05-02 16:20:39 +02:00
parent 9f03e29807
commit 4b2abbbfc5
14 changed files with 735 additions and 121 deletions
+144 -11
View File
@@ -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)