"""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 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).") @click.pass_context def cmd_auth_trust(ctx, pubkey, name, keys_file): """Add a public key to the authorized keys file (locally or on a remote 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) remote = (ctx.obj or {}).get("remote") if remote: from browser_cli.client import send_command result = send_command( "browser-cli.auth.trust", args={"pubkey": pubkey, "name": name}, 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]") 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) label = f" ({name})" if name else "" if added: console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]") 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[:]", 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 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_names path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(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