feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
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:
@@ -47,13 +47,23 @@ PAGEABLE_COMMANDS = {
|
||||
|
||||
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
|
||||
|
||||
def _read_exact_stream(stream, n: int) -> bytes | None:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = stream.read(n - len(buf))
|
||||
if not chunk:
|
||||
return None # real EOF
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def read_native_message(stream) -> dict | None:
|
||||
raw_len = stream.read(4)
|
||||
if len(raw_len) < 4:
|
||||
raw_len = _read_exact_stream(stream, 4)
|
||||
if raw_len is None:
|
||||
return None
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
data = stream.read(msg_len)
|
||||
if len(data) < msg_len:
|
||||
data = _read_exact_stream(stream, msg_len)
|
||||
if data is None:
|
||||
return None
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
@@ -125,10 +135,16 @@ def stdin_reader(alias: str):
|
||||
def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
|
||||
if is_windows():
|
||||
while True:
|
||||
listener = None
|
||||
try:
|
||||
listener = Listener(sock_path, family="AF_PIPE")
|
||||
conn = listener.accept()
|
||||
except OSError:
|
||||
if listener is not None:
|
||||
try:
|
||||
listener.close()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user