6fa931aa36
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels. - Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips. - Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely. - Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows. - Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0. - Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
"""Click commands for browser-cli remote authentication keys."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
from rich.console import Console
|
|
|
|
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
|
|
|
|
console = Console()
|
|
|
|
@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("\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("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
|
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
|
@command_policy_options
|
|
@click.pass_context
|
|
@handle_errors
|
|
def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
|
"""Add a public key to the authorized keys file (locally or on a remote serve host).
|
|
|
|
Pass --allow-read-page/--allow-control/--allow-dangerous/--allow-all to record a
|
|
per-key policy (an ``allow:`` token); without any, the key uses the server default.
|
|
"""
|
|
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)
|
|
|
|
categories = command_categories_from_options(
|
|
allow_read_page=allow_read_page, allow_control=allow_control,
|
|
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
|
|
)
|
|
policy_label = f" [dim]allow:{','.join(categories)}[/dim]" if categories else ""
|
|
|
|
remote = (ctx.obj or {}).get("remote")
|
|
if remote:
|
|
from browser_cli.client import send_command
|
|
args = {"pubkey": pubkey, "name": name}
|
|
if categories is not None:
|
|
args["allow"] = categories
|
|
result = send_command(
|
|
"browser-cli.auth.trust",
|
|
args=args,
|
|
remote=remote,
|
|
key=(ctx.obj or {}).get("key"),
|
|
)
|
|
added = (result or {}).get("added", False)
|
|
label = f" ({name})" if name else ""
|
|
if added:
|
|
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
|
else:
|
|
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
|
return
|
|
|
|
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
|
added = add_authorized_key(path, pubkey, name, categories)
|
|
label = f" ({name})" if name else ""
|
|
if added:
|
|
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
|
console.print(f" File: {path}")
|
|
console.print("\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")
|
|
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
|
@click.pass_context
|
|
@handle_errors
|
|
def cmd_auth_keys(ctx, keys_file):
|
|
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
|
from rich.table import Table
|
|
|
|
remote = (ctx.obj or {}).get("remote")
|
|
if remote:
|
|
from browser_cli.client import send_command
|
|
result = send_command(
|
|
"browser-cli.auth.keys",
|
|
remote=remote,
|
|
key=(ctx.obj or {}).get("key"),
|
|
)
|
|
entries = result or []
|
|
source_label = remote
|
|
else:
|
|
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
|
|
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
|
entries = [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
|
source_label = str(path)
|
|
|
|
if not entries:
|
|
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
|
|
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
|
|
return
|
|
|
|
table = Table(show_header=True, header_style="bold cyan")
|
|
table.add_column("Name")
|
|
table.add_column("Public Key")
|
|
table.add_column("Policy")
|
|
for entry in entries:
|
|
name = entry.get("name") or "[dim]—[/dim]"
|
|
table.add_row(name, entry.get("pubkey", ""), _policy_label(entry.get("allow")))
|
|
console.print(table)
|
|
|
|
def _policy_label(categories) -> str:
|
|
"""Render an authorized_keys ``allow:`` token for display."""
|
|
if categories is None:
|
|
return "[dim]server default[/dim]"
|
|
if "all" in categories:
|
|
return "[yellow]all[/yellow]"
|
|
return ", ".join(categories) if categories else "safe"
|