feat: add remote trust and server identity pinning
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints. - Add remote add/list/remove commands for explicit endpoint persistence. - Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing. - Add URL glob matching for tabs filter and count with extension tests. - Add n8n credential pinning for server public keys or SHA256 fingerprints. - Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes. - Bump browser-cli to 0.16.4 and n8n node to 0.3.1. - Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Persistent server identity keys for SSH-style remote host pinning."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
|
||||
|
||||
from browser_cli.constants import CONFIG_DIR
|
||||
|
||||
SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
|
||||
|
||||
def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
|
||||
"""Load the persistent serve identity key, creating it on first start."""
|
||||
if path.exists():
|
||||
key = load_pem_private_key(path.read_bytes(), password=None)
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError(f"server identity key is not Ed25519: {path}")
|
||||
return key
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
key = Ed25519PrivateKey.generate()
|
||||
pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||
fd = path.open("xb")
|
||||
try:
|
||||
fd.write(pem)
|
||||
finally:
|
||||
fd.close()
|
||||
path.chmod(0o600)
|
||||
return key
|
||||
|
||||
def public_key_hex(key: Ed25519PrivateKey) -> str:
|
||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
|
||||
def _signed_challenge_fields(challenge: dict) -> dict:
|
||||
return {key: value for key, value in challenge.items() if key != "server_sig"}
|
||||
|
||||
def challenge_payload(challenge: dict) -> bytes:
|
||||
"""Canonical bytes signed by the server identity key."""
|
||||
return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
|
||||
return key.sign(challenge_payload(challenge)).hex()
|
||||
|
||||
def verify_challenge_signature(challenge: dict) -> bool:
|
||||
pub_hex = challenge.get("server_pubkey")
|
||||
sig_hex = challenge.get("server_sig")
|
||||
if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
|
||||
return False
|
||||
try:
|
||||
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
|
||||
pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
|
||||
return True
|
||||
except (InvalidSignature, ValueError):
|
||||
return False
|
||||
@@ -243,6 +243,35 @@ def collect_browser_clients(
|
||||
return rows
|
||||
|
||||
if remote:
|
||||
targets = remote_browser_targets(remote, key=key)
|
||||
if browser_alias:
|
||||
targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
|
||||
if targets:
|
||||
uncached = []
|
||||
for target in targets:
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
rows.append(cached)
|
||||
else:
|
||||
uncached.append(target)
|
||||
results = _run_concurrent([
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in uncached
|
||||
])
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
return rows
|
||||
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.commands import handle_errors
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
||||
from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
|
||||
from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
|
||||
|
||||
console = Console()
|
||||
|
||||
def _print_remotes() -> None:
|
||||
remotes = load_remotes()
|
||||
if not remotes:
|
||||
console.print("[yellow]No remembered remotes[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Endpoint")
|
||||
table.add_column("Key")
|
||||
for endpoint, cfg in sorted(remotes.items()):
|
||||
table.add_row(endpoint, str(cfg.get("key", "")))
|
||||
console.print(table)
|
||||
|
||||
def _remove_remote(endpoint: str, *, verb: str) -> None:
|
||||
if not remove_remote(endpoint):
|
||||
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
|
||||
return
|
||||
console.print(f"[green]{verb} {endpoint}[/green]")
|
||||
|
||||
def _fetch_server_pubkey(endpoint: str) -> str:
|
||||
from browser_cli.auth.server_identity import verify_challenge_signature
|
||||
from browser_cli.remote.auth import parse_challenge
|
||||
from browser_cli.remote.socket import connect_socket, recv_all
|
||||
|
||||
sock = connect_socket(endpoint)
|
||||
try:
|
||||
challenge, _nonce = parse_challenge(recv_all(sock) or b"")
|
||||
finally:
|
||||
sock.close()
|
||||
if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
|
||||
raise BrowserNotConnected("remote server did not advertise a server identity key")
|
||||
if not verify_challenge_signature(challenge):
|
||||
raise BrowserNotConnected("remote server identity signature is invalid")
|
||||
return str(challenge["server_pubkey"])
|
||||
|
||||
@click.group("remote")
|
||||
def remote_group():
|
||||
"""Manage remembered browser-cli remote endpoints."""
|
||||
@@ -42,6 +77,17 @@ def remote_status(endpoint, key):
|
||||
browser_header="Profile",
|
||||
)
|
||||
|
||||
@remote_group.command("add")
|
||||
@click.argument("endpoint")
|
||||
@click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
|
||||
def remote_add(endpoint, key_spec):
|
||||
"""Remember a remote endpoint for global multi-browser commands."""
|
||||
save_remote(endpoint, key_spec)
|
||||
if key_spec:
|
||||
console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
|
||||
else:
|
||||
console.print(f"[green]Added remote {endpoint}[/green]")
|
||||
|
||||
@remote_group.command("trust")
|
||||
@click.argument("endpoint")
|
||||
@click.argument("key_spec")
|
||||
@@ -50,29 +96,58 @@ def remote_trust(endpoint, key_spec):
|
||||
save_remote_key(endpoint, key_spec)
|
||||
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
|
||||
|
||||
@remote_group.command("keys")
|
||||
def remote_keys():
|
||||
"""List remembered remote key specs."""
|
||||
remotes = load_remotes()
|
||||
if not remotes:
|
||||
console.print("[yellow]No remembered remotes[/yellow]")
|
||||
@remote_group.command("list")
|
||||
def remote_list():
|
||||
"""List remembered remote endpoints."""
|
||||
_print_remotes()
|
||||
|
||||
@remote_group.command("trust-host")
|
||||
@click.argument("endpoint")
|
||||
@click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
|
||||
@handle_errors
|
||||
def remote_trust_host(endpoint, pubkey):
|
||||
"""Pin a remote server identity key, SSH known_hosts style."""
|
||||
server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
|
||||
save_known_host(endpoint, server_pubkey)
|
||||
console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
|
||||
|
||||
@remote_group.command("known-hosts")
|
||||
def remote_known_hosts():
|
||||
"""List pinned remote server identity keys."""
|
||||
known = load_known_hosts()
|
||||
if not known:
|
||||
console.print("[yellow]No known remote server identities[/yellow]")
|
||||
return
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Endpoint")
|
||||
table.add_column("Key")
|
||||
for endpoint, cfg in sorted(remotes.items()):
|
||||
table.add_row(endpoint, str(cfg.get("key", "")))
|
||||
table.add_column("Fingerprint")
|
||||
table.add_column("Public Key")
|
||||
for endpoint, pubkey in sorted(known.items()):
|
||||
table.add_row(endpoint, fingerprint(pubkey), pubkey)
|
||||
console.print(table)
|
||||
|
||||
@remote_group.command("untrust-host")
|
||||
@click.argument("endpoint")
|
||||
def remote_untrust_host(endpoint):
|
||||
"""Remove a pinned remote server identity key."""
|
||||
if not remove_known_host(endpoint):
|
||||
console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
|
||||
return
|
||||
console.print(f"[green]Removed server identity for {endpoint}[/green]")
|
||||
|
||||
@remote_group.command("keys")
|
||||
def remote_keys():
|
||||
"""List remembered remote key specs."""
|
||||
_print_remotes()
|
||||
|
||||
@remote_group.command("remove")
|
||||
@click.argument("endpoint")
|
||||
def remote_remove(endpoint):
|
||||
"""Remove a remembered remote endpoint."""
|
||||
_remove_remote(endpoint, verb="Removed")
|
||||
|
||||
@remote_group.command("revoke")
|
||||
@click.argument("endpoint")
|
||||
def remote_revoke(endpoint):
|
||||
"""Remove remembered key/config for ENDPOINT."""
|
||||
remotes = load_remotes()
|
||||
if endpoint not in remotes:
|
||||
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
|
||||
return
|
||||
del remotes[endpoint]
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
console.print(f"[green]Revoked {endpoint}[/green]")
|
||||
_remove_remote(endpoint, verb="Revoked")
|
||||
|
||||
+13
-33
@@ -3,48 +3,28 @@ Auth-field normalizers — applied to the raw incoming message *before* the
|
||||
auth check runs. Protocol fields (pubkey, sig, …) are still present here.
|
||||
|
||||
Add one entry per breaking auth-field change:
|
||||
("X.Y.Z", transformer_fn)
|
||||
("X.Y.Z", transformer_fn)
|
||||
|
||||
Entries must stay in ascending version order.
|
||||
|
||||
The registry is intentionally empty: the first public release was 0.14.1, so no
|
||||
legacy-client shim has ever been needed. This module is the seam — add a tuple
|
||||
here (and a unit test for it) the day a breaking auth-field change ships.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Callable
|
||||
from browser_cli.version_manager import parse_version
|
||||
|
||||
|
||||
# ── v0.9.3 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _auth_0_9_3(msg: dict) -> dict:
|
||||
"""pubkey validation tightened to lowercase hex; normalize for older clients."""
|
||||
changed: dict = {}
|
||||
pk = msg.get("pubkey")
|
||||
if isinstance(pk, str) and pk:
|
||||
changed["pubkey"] = pk.lower()
|
||||
if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
|
||||
args = msg.get("args") or {}
|
||||
trust_pk = args.get("pubkey")
|
||||
identifier = args.get("identifier")
|
||||
patched = dict(args)
|
||||
if isinstance(trust_pk, str) and trust_pk:
|
||||
patched["pubkey"] = trust_pk.lower()
|
||||
if isinstance(identifier, str) and identifier and len(identifier) == 64:
|
||||
patched["identifier"] = identifier.lower()
|
||||
if patched != args:
|
||||
changed["args"] = patched
|
||||
return {**msg, **changed} if changed else msg
|
||||
|
||||
|
||||
# ── registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
|
||||
("0.9.3", _auth_0_9_3),
|
||||
]
|
||||
|
||||
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
|
||||
|
||||
def adapt_auth(msg: dict, client_version: str) -> dict:
|
||||
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
||||
cv = parse_version(client_version)
|
||||
for version, fn in _AUTH_COMPAT:
|
||||
if cv < parse_version(version):
|
||||
msg = fn(msg)
|
||||
"""Apply all auth normalizers needed to bring msg up to the current format."""
|
||||
if not _AUTH_COMPAT:
|
||||
return msg
|
||||
cv = parse_version(client_version)
|
||||
for version, fn in _AUTH_COMPAT:
|
||||
if cv < parse_version(version):
|
||||
msg = fn(msg)
|
||||
return msg
|
||||
|
||||
@@ -3,7 +3,7 @@ Command-format shims — applied to clean_msg (protocol fields already stripped)
|
||||
before forwarding to the native host, and to responses before sending back.
|
||||
|
||||
Add one entry per breaking command-format change:
|
||||
("X.Y.Z", request_fn, response_fn)
|
||||
("X.Y.Z", request_fn, response_fn)
|
||||
|
||||
- request_fn(msg: dict) -> dict or None
|
||||
- response_fn(resp: bytes, command: str) -> bytes or None
|
||||
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
|
||||
Entries must stay in ascending version order.
|
||||
adapt_request walks forward (oldest first); adapt_response walks backward.
|
||||
|
||||
Current baseline: 0.9.3 — no command-format shims needed yet.
|
||||
The registry is intentionally empty: no command-format shim has been needed
|
||||
since the first public release (0.14.1). This module is the seam — add a tuple
|
||||
here (and a unit test for it) the day a breaking command-format change ships.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Callable
|
||||
from browser_cli.version_manager import parse_version
|
||||
|
||||
|
||||
# ── registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
|
||||
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
|
||||
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
|
||||
]
|
||||
|
||||
|
||||
def adapt_request(msg: dict, client_version: str) -> dict:
|
||||
"""Upgrade a client message to the current browser command format."""
|
||||
cv = parse_version(client_version)
|
||||
for version, req_fn, _ in _COMPAT:
|
||||
if cv < parse_version(version) and req_fn is not None:
|
||||
msg = req_fn(msg)
|
||||
"""Upgrade a client message to the current browser command format."""
|
||||
if not _COMPAT:
|
||||
return msg
|
||||
|
||||
cv = parse_version(client_version)
|
||||
for version, req_fn, _ in _COMPAT:
|
||||
if cv < parse_version(version) and req_fn is not None:
|
||||
msg = req_fn(msg)
|
||||
return msg
|
||||
|
||||
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
|
||||
"""Downgrade a native-host response to the format the client expects."""
|
||||
cv = parse_version(client_version)
|
||||
for version, _, resp_fn in reversed(_COMPAT):
|
||||
if cv < parse_version(version) and resp_fn is not None:
|
||||
resp = resp_fn(resp, command)
|
||||
"""Downgrade a native-host response to the format the client expects."""
|
||||
if not _COMPAT:
|
||||
return resp
|
||||
cv = parse_version(client_version)
|
||||
for version, _, resp_fn in reversed(_COMPAT):
|
||||
if cv < parse_version(version) and resp_fn is not None:
|
||||
resp = resp_fn(resp, command)
|
||||
return resp
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""SSH-style known-hosts pinning for browser-cli remote servers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.constants import CONFIG_DIR
|
||||
from browser_cli.endpoints import _normalize_endpoint
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
|
||||
KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
|
||||
|
||||
def fingerprint(pubkey_hex: str) -> str:
|
||||
"""Return a compact SHA256 fingerprint for display."""
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
|
||||
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
def load_known_hosts(path: Path | None = None) -> dict[str, str]:
|
||||
path = path or KNOWN_HOSTS_PATH
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
|
||||
|
||||
def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
|
||||
path = path or KNOWN_HOSTS_PATH
|
||||
known = load_known_hosts(path)
|
||||
known[_normalize_endpoint(endpoint)] = pubkey_hex
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
||||
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
|
||||
path = path or KNOWN_HOSTS_PATH
|
||||
known = load_known_hosts(path)
|
||||
normalized = _normalize_endpoint(endpoint)
|
||||
if normalized not in known:
|
||||
return False
|
||||
del known[normalized]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
||||
file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
|
||||
return True
|
||||
|
||||
def _is_loopback_endpoint(endpoint: str) -> bool:
|
||||
host, sep, _port = endpoint.rpartition(":")
|
||||
check = host if sep else endpoint
|
||||
return check in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
def verify_known_host(endpoint: str, challenge: dict | None) -> None:
|
||||
"""Verify and pin the server identity from a challenge frame.
|
||||
|
||||
First contact auto-adds the host when the process is interactive, mirroring
|
||||
SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
|
||||
`browser-cli remote trust-host ENDPOINT`.
|
||||
"""
|
||||
if not isinstance(challenge, dict):
|
||||
return
|
||||
pubkey = challenge.get("server_pubkey")
|
||||
if not isinstance(pubkey, str) or not pubkey:
|
||||
return
|
||||
|
||||
from browser_cli.auth.server_identity import verify_challenge_signature
|
||||
if not verify_challenge_signature(challenge):
|
||||
raise BrowserNotConnected("Remote server identity signature is invalid")
|
||||
|
||||
normalized = _normalize_endpoint(endpoint)
|
||||
known = load_known_hosts()
|
||||
expected = known.get(normalized)
|
||||
if expected is None and _is_loopback_endpoint(endpoint):
|
||||
return
|
||||
if expected is None:
|
||||
if not sys.stdin.isatty():
|
||||
raise BrowserNotConnected(
|
||||
f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
|
||||
f"Run: browser-cli remote trust-host {normalized}"
|
||||
)
|
||||
sys.stderr.write(
|
||||
f"The authenticity of remote '{normalized}' can't be established.\n"
|
||||
f"Server key fingerprint is {fingerprint(pubkey)}.\n"
|
||||
"Trust this server and add it to known hosts? [y/N] "
|
||||
)
|
||||
answer = sys.stdin.readline().strip().lower()
|
||||
if answer not in {"y", "yes"}:
|
||||
raise BrowserNotConnected("Remote server identity was not trusted")
|
||||
save_known_host(normalized, pubkey)
|
||||
sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
|
||||
return
|
||||
|
||||
if expected != pubkey:
|
||||
raise BrowserNotConnected(
|
||||
f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
|
||||
f"Known: {fingerprint(expected)}\n"
|
||||
f"Seen: {fingerprint(pubkey)}\n"
|
||||
f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
|
||||
)
|
||||
@@ -28,18 +28,39 @@ def is_valid_key_spec(value: str) -> bool:
|
||||
not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
|
||||
)
|
||||
|
||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
||||
if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
|
||||
def save_remote(endpoint: str, key_spec: str | None = None) -> None:
|
||||
"""Persist a remote endpoint, optionally with a key spec."""
|
||||
if not endpoint:
|
||||
return
|
||||
normalized = _normalize_endpoint(endpoint)
|
||||
remotes = load_remotes()
|
||||
current = remotes.get(endpoint, {})
|
||||
current["key"] = key_spec
|
||||
remotes[endpoint] = current
|
||||
current = remotes.get(normalized, {})
|
||||
if key_spec:
|
||||
if not is_valid_key_spec(key_spec):
|
||||
return
|
||||
current["key"] = key_spec
|
||||
remotes[normalized] = current
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True))
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
def remove_remote(endpoint: str) -> bool:
|
||||
"""Remove a remembered remote endpoint. Returns True when it existed."""
|
||||
normalized = _normalize_endpoint(endpoint)
|
||||
remotes = load_remotes()
|
||||
if normalized not in remotes:
|
||||
return False
|
||||
del remotes[normalized]
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
|
||||
return True
|
||||
|
||||
def save_remote_key(endpoint: str, key_spec: str) -> None:
|
||||
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
|
||||
save_remote(endpoint, key_spec)
|
||||
|
||||
def key_for_remote(endpoint: str | None) -> str | None:
|
||||
if not endpoint:
|
||||
|
||||
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
|
||||
split_endpoint as _split_endpoint,
|
||||
)
|
||||
from browser_cli.remote import pool as _pool
|
||||
from browser_cli.remote.known_hosts import verify_known_host
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
# Reuse an already-authenticated connection when one is idle for this endpoint.
|
||||
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
|
||||
|
||||
sock = _connect_socket(endpoint)
|
||||
try:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
challenge_raw = _recv_all(sock)
|
||||
challenge, _nonce_hex = _parse_challenge(challenge_raw)
|
||||
verify_known_host(endpoint, challenge)
|
||||
payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
except BaseException:
|
||||
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
|
||||
reader, writer = await _open_async_connection(endpoint)
|
||||
try:
|
||||
challenge_raw = await _async_recv_all(reader)
|
||||
challenge, _nonce_hex = _parse_challenge(challenge_raw)
|
||||
verify_known_host(endpoint, challenge)
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
|
||||
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
|
||||
if pq_keypair is not None:
|
||||
pq_private_key, pq_public_key = pq_keypair
|
||||
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
||||
from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
|
||||
server_key = await asyncio.to_thread(load_or_create_server_identity)
|
||||
challenge_msg["server_pubkey"] = public_key_hex(server_key)
|
||||
challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
|
||||
return nonce, pq_private_key, challenge_msg
|
||||
|
||||
Reference in New Issue
Block a user