7fe0e27fec
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve. - Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes. - Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected. - Show policy descriptions in auth keys output so each capability is easier to understand. - Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates. - Bump real-browser-cli to 0.16.2 and lock the new questionary dependency. - Cover local, remote, validation, and policy-category behavior in tests.
437 lines
17 KiB
Python
437 lines
17 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("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[:<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")
|
|
table.add_column("Description")
|
|
for entry in entries:
|
|
name = entry.get("name") or "[dim]—[/dim]"
|
|
allow = entry.get("allow")
|
|
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
|
|
console.print(table)
|
|
|
|
def _load_policy_entries(ctx, keys_file):
|
|
"""Load trusted-key entries for interactive selection."""
|
|
remote = (ctx.obj or {}).get("remote")
|
|
if remote:
|
|
from browser_cli.client import send_command
|
|
return send_command(
|
|
"browser-cli.auth.keys",
|
|
remote=remote,
|
|
key=(ctx.obj or {}).get("key"),
|
|
) or []
|
|
|
|
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
|
|
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
|
|
|
def _find_policy_entry(ctx, keys_file, identifier: str):
|
|
"""Find the current key entry so the checkbox prompt can preselect values."""
|
|
wanted = identifier.strip()
|
|
for entry in _load_policy_entries(ctx, keys_file):
|
|
pubkey = str(entry.get("pubkey") or "")
|
|
name = str(entry.get("name") or "")
|
|
if pubkey.lower() == wanted.lower() or (name and name == wanted):
|
|
return entry
|
|
return None
|
|
|
|
def _prompt_key_entry(entries):
|
|
"""Interactive checkbox flow step 1: choose which key to edit."""
|
|
if not entries:
|
|
raise click.ClickException("no trusted keys found")
|
|
|
|
import questionary
|
|
|
|
choices = []
|
|
for entry in entries:
|
|
name = entry.get("name") or "unnamed key"
|
|
pubkey = entry.get("pubkey") or ""
|
|
policy = _plain_policy_label(entry.get("allow"))
|
|
choices.append(questionary.Choice(
|
|
title=f"{name} [{policy}] {pubkey[:12]}…{pubkey[-8:]}",
|
|
value=entry,
|
|
))
|
|
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
|
|
if selected is None:
|
|
raise click.ClickException("cancelled")
|
|
return selected
|
|
|
|
def _prompt_policy_categories(identifier: str, current_categories=None):
|
|
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
|
|
import questionary
|
|
|
|
checked = set(current_categories or [])
|
|
special_checked = {
|
|
"__server_default__": current_categories is None,
|
|
"__safe__": current_categories == [],
|
|
"__all__": isinstance(current_categories, list) and "all" in current_categories,
|
|
}
|
|
choices = [
|
|
questionary.Choice(
|
|
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
|
|
value="read-page",
|
|
checked="read-page" in checked,
|
|
),
|
|
questionary.Choice(
|
|
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
|
|
value="control",
|
|
checked="control" in checked,
|
|
),
|
|
questionary.Choice(
|
|
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
|
|
value="dangerous",
|
|
checked="dangerous" in checked,
|
|
),
|
|
questionary.Choice(
|
|
title="keys — admin access to key management over --remote: auth keys/trust/policy",
|
|
value="keys",
|
|
checked="keys" in checked,
|
|
),
|
|
questionary.Separator(),
|
|
questionary.Choice(
|
|
title="all — allow everything",
|
|
value="__all__",
|
|
checked=special_checked["__all__"],
|
|
),
|
|
questionary.Choice(
|
|
title="safe — explicit safe-only override",
|
|
value="__safe__",
|
|
checked=special_checked["__safe__"],
|
|
),
|
|
questionary.Choice(
|
|
title="server default — remove per-key override and inherit server policy",
|
|
value="__server_default__",
|
|
checked=special_checked["__server_default__"],
|
|
),
|
|
]
|
|
selected = questionary.checkbox(
|
|
f"Policy for {identifier}",
|
|
choices=choices,
|
|
instruction="(space to toggle, enter to save)",
|
|
).ask()
|
|
if selected is None:
|
|
raise click.ClickException("cancelled")
|
|
return _parse_checkbox_policy_selection(selected)
|
|
|
|
def _parse_checkbox_policy_selection(selected):
|
|
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
|
|
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
|
|
if len(special) > 1 or (special and normal):
|
|
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
|
|
if special == ["__server_default__"]:
|
|
return None
|
|
if special == ["__safe__"]:
|
|
return []
|
|
if special == ["__all__"]:
|
|
return ["all"]
|
|
return normal
|
|
|
|
def _parse_policy_selection(raw: str):
|
|
value = raw.strip().lower()
|
|
if value in {"default", "server-default", "server default", "inherit", "none"}:
|
|
return None
|
|
if value in {"safe", "safe-only", ""}:
|
|
return []
|
|
if value == "all":
|
|
return ["all"]
|
|
|
|
number_map = {
|
|
"1": "read-page",
|
|
"2": "control",
|
|
"3": "dangerous",
|
|
"4": "keys",
|
|
}
|
|
valid = {"read-page", "control", "dangerous", "keys"}
|
|
categories = []
|
|
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
|
|
category = number_map.get(token, token)
|
|
if category == "all":
|
|
return ["all"]
|
|
if category not in valid:
|
|
raise click.ClickException(f"unknown policy choice: {token}")
|
|
if category not in categories:
|
|
categories.append(category)
|
|
return categories
|
|
|
|
def _plain_policy_label(categories) -> str:
|
|
"""Plain-text policy label for interactive prompt titles."""
|
|
if categories is None:
|
|
return "server default"
|
|
if "all" in categories:
|
|
return "all"
|
|
return ", ".join(categories) if categories else "safe"
|
|
|
|
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"
|
|
|
|
def _policy_description(categories) -> str:
|
|
"""Human-readable explanation for a policy category list."""
|
|
if categories is None:
|
|
return "Inherits the policy from browser-cli serve"
|
|
if "all" in categories:
|
|
return "Full access: page reads, browser control, dangerous commands, key admin"
|
|
if not categories:
|
|
return "Safe status/list commands only"
|
|
|
|
descriptions = {
|
|
"read-page": "read page content",
|
|
"control": "control browser/tabs/page input",
|
|
"dangerous": "run high-risk commands",
|
|
"keys": "manage trusted keys remotely",
|
|
}
|
|
return "; ".join(descriptions.get(category, category) for category in categories)
|