feat: auth keys shows trusted keys with names; remote auth trust/keys
- authorized_keys format extended to '<hex> [optional-name]' - auth keys repurposed: shows server's trusted keys (Name/Public Key table) instead of local client keys; --remote queries the remote serve instance - auth trust gains --name flag for labelling keys; --remote pushes the key to the remote server's authorized_keys - serve.py handles browser-cli.auth.keys and browser-cli.auth.trust as server-side commands (authenticated, never forwarded to native host) - serve.py reloads authorized_keys from disk on every connection so auth trust --remote takes effect immediately without restarting serve - auth show unchanged: still prints your own client public key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
-9
@@ -182,22 +182,33 @@ def new_nonce() -> str:
|
|||||||
return secrets.token_hex(32)
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
def load_authorized_keys(path: Path) -> list[str]:
|
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
return [
|
result = []
|
||||||
line.strip()
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
for line in path.read_text(encoding="utf-8").splitlines()
|
line = line.strip()
|
||||||
if line.strip() and not line.startswith("#")
|
if not line or line.startswith("#"):
|
||||||
]
|
continue
|
||||||
|
parts = line.split(None, 1)
|
||||||
|
pubkey = parts[0]
|
||||||
|
name = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
result.append((pubkey, name))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def add_authorized_key(path: Path, pub_hex: str) -> bool:
|
def load_authorized_keys(path: Path) -> list[str]:
|
||||||
|
return [pk for pk, _ in load_authorized_keys_with_names(path)]
|
||||||
|
|
||||||
|
|
||||||
|
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
existing = set(load_authorized_keys(path))
|
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
|
||||||
if pub_hex in existing:
|
if pub_hex in existing:
|
||||||
return False
|
return False
|
||||||
|
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||||
with open(path, "a", encoding="utf-8") as f:
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
f.write(pub_hex + "\n")
|
f.write(line)
|
||||||
return True
|
return True
|
||||||
|
|||||||
+54
-33
@@ -252,9 +252,11 @@ def cmd_auth_keygen(output, force):
|
|||||||
|
|
||||||
@auth_group.command("trust")
|
@auth_group.command("trust")
|
||||||
@click.argument("pubkey")
|
@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.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):
|
@click.pass_context
|
||||||
"""Add a public key to the authorized keys file on the serve host."""
|
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
|
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||||
|
|
||||||
if len(pubkey) != 64:
|
if len(pubkey) != 64:
|
||||||
@@ -266,10 +268,28 @@ def cmd_auth_trust(pubkey, keys_file):
|
|||||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
remote = (ctx.obj or {}).get("remote")
|
||||||
added = add_authorized_key(path, pubkey)
|
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:
|
if added:
|
||||||
console.print(f"[green]✓[/green] Trusted: [cyan]{pubkey}[/cyan]")
|
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(f" File: {path}")
|
||||||
console.print(f"\nStart the server with:")
|
console.print(f"\nStart the server with:")
|
||||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||||
@@ -313,39 +333,40 @@ def cmd_auth_show(key_src):
|
|||||||
|
|
||||||
|
|
||||||
@auth_group.command("keys")
|
@auth_group.command("keys")
|
||||||
def cmd_auth_keys():
|
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||||
"""List all Ed25519 keys available for pubkey auth (file + SSH agent)."""
|
@click.pass_context
|
||||||
from browser_cli.auth import DEFAULT_KEY_PATH, agent_list_keys, load_private_key, public_key_hex
|
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
|
from rich.table import Table
|
||||||
|
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
remote = (ctx.obj or {}).get("remote")
|
||||||
table.add_column("Source")
|
if remote:
|
||||||
table.add_column("Comment / Path")
|
from browser_cli.client import send_command
|
||||||
table.add_column("Public Key")
|
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)
|
||||||
|
|
||||||
# File key
|
if not entries:
|
||||||
if DEFAULT_KEY_PATH.exists():
|
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
|
||||||
try:
|
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
|
||||||
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
|
return
|
||||||
|
|
||||||
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
|
table.add_column("Name")
|
||||||
|
table.add_column("Public Key")
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.get("name") or "[dim]—[/dim]"
|
||||||
|
table.add_row(name, entry.get("pubkey", ""))
|
||||||
console.print(table)
|
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)
|
main.add_command(auth_group)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
|
|||||||
else:
|
else:
|
||||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||||
|
|
||||||
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, nonce:str) -> None:
|
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
|
||||||
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||||
from browser_cli.platform import is_windows
|
from browser_cli.platform import is_windows
|
||||||
|
|
||||||
@@ -87,6 +87,37 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
|||||||
_log(addr, command, None, "OK")
|
_log(addr, command, None, "OK")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if command == "browser-cli.auth.keys":
|
||||||
|
if auth_keys_path is None:
|
||||||
|
_send_error(msg_id, "no authorized keys file configured on this server")
|
||||||
|
_log(addr, command, None, "ERROR", "no authorized keys file")
|
||||||
|
return
|
||||||
|
from browser_cli.auth import load_authorized_keys_with_names
|
||||||
|
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
|
||||||
|
data = json.dumps({"id": msg_id, "success": True, "data": entries}).encode()
|
||||||
|
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
||||||
|
_log(addr, command, None, "OK")
|
||||||
|
return
|
||||||
|
|
||||||
|
if command == "browser-cli.auth.trust":
|
||||||
|
if auth_keys_path is None:
|
||||||
|
_send_error(msg_id, "no authorized keys file configured on this server")
|
||||||
|
_log(addr, command, None, "ERROR", "no authorized keys file")
|
||||||
|
return
|
||||||
|
from browser_cli.auth import add_authorized_key
|
||||||
|
args = msg.get("args") or {}
|
||||||
|
pubkey = str(args.get("pubkey") or "")
|
||||||
|
name = str(args.get("name") or "")
|
||||||
|
if len(pubkey) != 64:
|
||||||
|
_send_error(msg_id, "invalid pubkey: expected 64 hex characters")
|
||||||
|
_log(addr, command, None, "ERROR", "invalid pubkey")
|
||||||
|
return
|
||||||
|
added = add_authorized_key(auth_keys_path, pubkey, name)
|
||||||
|
data = json.dumps({"id": msg_id, "success": True, "data": {"added": added}}).encode()
|
||||||
|
client_sock.sendall(struct.pack("<I", len(data)) + data)
|
||||||
|
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||||
|
return
|
||||||
|
|
||||||
resolved_profile = msg.get("_route") or profile
|
resolved_profile = msg.get("_route") or profile
|
||||||
|
|
||||||
strip = {"token", "_route", "pubkey", "sig"}
|
strip = {"token", "_route", "pubkey", "sig"}
|
||||||
@@ -129,20 +160,26 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
|
|||||||
_send_error(msg_id, str(e))
|
_send_error(msg_id, str(e))
|
||||||
_log(addr, command, resolved_profile, "ERROR", str(e))
|
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||||
|
|
||||||
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None) -> None:
|
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys_path:"Path|None") -> None:
|
||||||
if not _CONN_LIMIT.acquire(blocking=False):
|
if not _CONN_LIMIT.acquire(blocking=False):
|
||||||
client_sock.close()
|
client_sock.close()
|
||||||
return
|
return
|
||||||
client_sock.settimeout(30)
|
client_sock.settimeout(30)
|
||||||
try:
|
try:
|
||||||
with client_sock:
|
with client_sock:
|
||||||
|
# reload on every connection so auth trust --remote takes effect immediately
|
||||||
|
if auth_keys_path is not None:
|
||||||
|
from browser_cli.auth import load_authorized_keys
|
||||||
|
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
|
||||||
|
else:
|
||||||
|
auth_keys = None
|
||||||
nonce = secrets.token_hex(32)
|
nonce = secrets.token_hex(32)
|
||||||
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
|
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
|
||||||
try:
|
try:
|
||||||
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
|
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
_proxy_request(client_sock, addr, profile, server_token, auth_keys, nonce)
|
_proxy_request(client_sock, addr, profile, server_token, auth_keys, auth_keys_path, nonce)
|
||||||
finally:
|
finally:
|
||||||
_CONN_LIMIT.release()
|
_CONN_LIMIT.release()
|
||||||
|
|
||||||
@@ -163,16 +200,15 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
|||||||
|
|
||||||
if auth_keys_file:
|
if auth_keys_file:
|
||||||
from browser_cli.auth import load_authorized_keys
|
from browser_cli.auth import load_authorized_keys
|
||||||
path = Path(auth_keys_file)
|
auth_keys_path = Path(auth_keys_file)
|
||||||
auth_keys = load_authorized_keys(path)
|
if not load_authorized_keys(auth_keys_path):
|
||||||
if not auth_keys:
|
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
|
||||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {path}")
|
|
||||||
server_token = None
|
server_token = None
|
||||||
elif no_auth:
|
elif no_auth:
|
||||||
auth_keys = None
|
auth_keys_path = None
|
||||||
server_token = None
|
server_token = None
|
||||||
else:
|
else:
|
||||||
auth_keys = None
|
auth_keys_path = None
|
||||||
server_token = token or secrets.token_urlsafe(32)
|
server_token = token or secrets.token_urlsafe(32)
|
||||||
|
|
||||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
@@ -188,8 +224,10 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
|||||||
browser_hint = f" (browser: {profile})" if profile else ""
|
browser_hint = f" (browser: {profile})" if profile else ""
|
||||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
|
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
|
||||||
|
|
||||||
if auth_keys is not None:
|
if auth_keys_path is not None:
|
||||||
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({len(auth_keys)} trusted key{'s' if len(auth_keys) != 1 else ''})")
|
from browser_cli.auth import load_authorized_keys
|
||||||
|
n = len(load_authorized_keys(auth_keys_path))
|
||||||
|
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
|
||||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||||
elif server_token:
|
elif server_token:
|
||||||
@@ -206,7 +244,7 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
conn, addr = server.accept()
|
conn, addr = server.accept()
|
||||||
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys), daemon=True).start()
|
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys_path), daemon=True).start()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("[yellow]Stopped.[/yellow]")
|
console.print("[yellow]Stopped.[/yellow]")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user