"""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("policy") @click.argument("identifier", required=False) @click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).") @click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.") @click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).") @command_policy_options @click.pass_context @handle_errors def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all): """Change a trusted key's per-key policy. IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in an interactive terminal to pick a key first, then edit the policy with real checkbox prompts. Use --safe for an explicit safe-only override, --server-default to remove the override, or one or more --allow-* flags for scriptable/non-interactive usage. """ from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all]) modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled) if modes > 1: console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags") sys.exit(1) is_interactive = click.get_text_stream("stdin").isatty() current_categories = None if not identifier: if not is_interactive: console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one") sys.exit(1) entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file)) identifier = entry.get("pubkey") or entry.get("name") or "" current_categories = entry.get("allow") elif modes == 0 and is_interactive: entry = _find_policy_entry(ctx, keys_file, identifier) current_categories = entry.get("allow") if entry else None if server_default: categories = None elif safe_only: categories = [] elif explicit_allow: 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, ) else: if not is_interactive: console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively") sys.exit(1) categories = _prompt_policy_categories(identifier, current_categories) remote = (ctx.obj or {}).get("remote") if remote: from browser_cli.client import send_command result = send_command( "browser-cli.auth.policy", args={"identifier": identifier, "allow": categories}, remote=remote, key=(ctx.obj or {}).get("key"), ) name = (result or {}).get("name") or "" pubkey = (result or {}).get("pubkey") or identifier label = f" ({name})" if name else "" console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}") return path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH try: updated = set_authorized_key_policy(path, identifier, categories) except ValueError as exc: console.print(f"[red]{exc}[/red]") sys.exit(1) if updated is None: console.print(f"[red]Trusted key not found:[/red] {identifier}") sys.exit(1) pubkey, name = updated label = f" ({name})" if name else "" console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}") console.print(f" File: {path}") @auth_group.command("show") @click.option( "--key", "key_src", default=None, metavar="PATH|agent[:]", help="Key source: path to PEM file, 'agent', or 'agent:'.", ) 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 --name