feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Testing / test (push) Successful in 26s
Package Extension / package-extension (push) Successful in 22s
Build & Publish Package / publish (push) Successful in 27s

Security:
- serve.py: server now sends nonce challenge before accepting any command;
  clients sign nonce + SHA256(canonical_payload) with Ed25519 key
- New --authorized-keys FILE option for serve; token auth still works as fallback
- Connection limit: BoundedSemaphore(64) in serve.py
- Secure file creation with os.open(..., 0o600) for token/key files
- New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python),
  sign/verify helpers compatible with both file keys and agent-held keys (YubiKey,
  TPM, gpg-agent)

Features:
- YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK
- New `browser-cli auth` command group: keygen, trust, show, keys
- Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key;
  pass "agent" or "agent:<selector>" to use SSH agent key
- BrowserCLI Python API gains key= parameter

Bug fixes (11 issues across two review passes):
- client.py: check response is not None before json.loads
- native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows
  Listener leak on accept error
- __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None
- extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback
- extension/session.ts: removeListener before addListener prevents duplicate handlers

Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:20:39 +02:00
parent 9f03e29807
commit 4b2abbbfc5
14 changed files with 735 additions and 121 deletions
+5 -11
View File
@@ -118,15 +118,13 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent.update(msg)
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list", remote="host:8765", token="secret") == "ok"
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
@@ -142,9 +140,7 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
@@ -198,9 +194,7 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
@@ -246,7 +240,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
def fake_send_command(command, args=None, profile=None, remote=None, token=None):
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
assert command == "browser-cli.targets"
assert remote == endpoint
assert token == "secret-token"