Compare commits

...

12 Commits

Author SHA1 Message Date
daniel156161 eaa1469143 fix(extension): detect browser error pages earlier
Testing / test (push) Successful in 26s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 27s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 20s
Package Extension / package-extension (push) Successful in 28s
Build & Publish Package / publish (push) Successful in 31s
- Add shared browser error URL detection for Chrome, Edge, Brave, and Firefox-style about:error pages.
- Short-circuit read-only DOM and HTML commands with safe fallbacks when tabs are already on browser error pages.
- Fail navigation waits, DOM waits, polling, and URL watches with clearer error-page messages.
- Bump package and extension version to 0.9.8 and extend regression coverage for cross-browser error-page handling.
2026-05-14 13:54:21 +02:00
daniel156161 f79ff0e3c2 fix(extension): handle browser error pages gracefully
Testing / test (push) Successful in 37s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 39s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 24s
- Treat chrome error page script failures as transient during injection retries.
- Return safe fallback values for read-only DOM commands when tabs land on browser error pages.
- Improve URL watch handling by checking pending URLs and reporting last seen URL/status on timeout.
- Bump package and extension version to 0.9.6 and add regression coverage for error-page behavior.
2026-05-14 13:39:09 +02:00
daniel156161 a8b433aa29 Add remote protocol compatibility workflow
Testing / test (push) Successful in 25s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 26s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 20s
2026-05-05 11:05:49 +02:00
daniel156161 94c87e244b Encrypt remote transport with post-quantum session keys
Testing / test (push) Successful in 21s
Package Extension / package-extension (push) Successful in 18s
Build & Publish Package / publish (push) Successful in 29s
2026-05-05 10:49:38 +02:00
daniel156161 9096efd36a Fix ML-KEM encapsulation ordering
Testing / test (push) Successful in 22s
2026-05-05 10:40:06 +02:00
daniel156161 98396a7c7e Add post-quantum remote auth key exchange
Testing / test (push) Successful in 32s
2026-05-05 10:34:28 +02:00
daniel156161 30a42ba6d5 fix(auth): skip agent keys with comment (none)
Testing / test (push) Successful in 29s
gpg-agent retains YubiKey entries after card removal but resets the
comment to "(none)". Treating those as valid keys causes auth to
succeed against a ghost identity — skip them so the caller gets None
and the missing-card error path fires correctly.
2026-05-03 17:08:26 +02:00
daniel156161 533e9d328d fix: drop browser-cli ALPN restriction on TLS port 443
Testing / test (push) Failing after 14m32s
set_alpn_protocols(["browser-cli"]) caused TLS handshake failure
(no_application_protocol alert) when connecting through a reverse
proxy (e.g. Traefik) that terminates TLS but doesn't know the custom
ALPN. Plain TLS without ALPN negotiation works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:58:24 +02:00
daniel156161 9177e989bd feat: default port 443 for domain remotes, strip from display (v0.9.4)
Testing / test (push) Failing after 13m12s
- Domain-like --remote endpoints default to port 443; :443 is optional
- _normalize_endpoint strips :443 before storage in remotes.json
- _load_remotes normalises keys on load (backward compat migration)
- _remote_display_name omits :443 for domain endpoints
- _resolve_connect_endpoint adds :443 back for TCP connection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:46:27 +02:00
daniel156161 7fd966014f set alpn protocol to browser-cli prevent h2/http1.1 ALPN confusion
Testing / test (push) Failing after 14m46s
2026-05-03 12:42:59 +02:00
daniel156161 217641d0ef fix: auto-wrap TLS for port 443 in _send_remote
Testing / test (push) Successful in 27s
Port 443 → ssl.create_default_context().wrap_socket() before the
challenge handshake so Traefik TCP routers with TLS termination work.
Other ports stay plain TCP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:43:27 +02:00
daniel156161 0d5c49c19a refactor: split compat into package, harden serve proxy (v0.9.3)
Testing / test (push) Failing after 10m21s
- compat.py → compat/ package: auth.py (auth-field normalizers),
  commands.py (command-format shims), __init__.py (re-exports)
- Add _auth_0_9_3 transformer: normalizes pubkey to lowercase before auth
  so clients < 0.9.3 sending uppercase hex are accepted
- adapt_auth() now called before auth check in serve.py; command extracted
  after adapt_auth so future transformers can rename commands safely
- serve.py: deduplicate _recv_exact (import from client), unify
  resp/resp_payload across Windows/Unix branches, require lowercase hex
  pubkey (re.fullmatch), reorganize imports, drop unused os import
- client.py: move payload/framed construction inside branches (remote path
  no longer serializes JSON it never uses); fix _is_valid_key_spec
  operator precedence; import MAX_MSG_BYTES from version_manager
- auth.py: narrow except clause (ValueError instead of bare Exception)
- Bump version 0.9.2 → 0.9.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 10:12:55 +02:00
36 changed files with 1648 additions and 489 deletions
+24
View File
@@ -21,3 +21,27 @@ jobs:
- name: Run tests
run: uv run pytest
remote-protocol-compat:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser-cli-client-version:
- "0.9.3"
- "0.9.5"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --group dev --managed-python
- name: Run remote protocol compatibility matrix
env:
BROWSER_CLI_COMPAT_CLIENT_VERSION: ${{ matrix.browser-cli-client-version }}
run: uv run pytest tests/test_remote_protocol_matrix.py -v
+86 -10
View File
@@ -1,4 +1,4 @@
"""Ed25519 keypair management and challenge-response auth helpers."""
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
import hashlib
import json
import os
@@ -10,6 +10,9 @@ from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
@@ -103,6 +106,8 @@ def agent_find_key(selector: str | None = None) -> AgentKey | None:
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
@@ -150,34 +155,105 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
# ── Canonical payload + sign/verify ───────────────────────────────────────────
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth fields."""
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig"}},
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict) -> bytes:
"""Sign nonce + SHA256(canonical_payload(msg)) — works for both file keys and agent keys."""
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = _auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str) -> bool:
"""Return True if sig_hex is a valid Ed25519 signature over the canonical payload."""
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
pub_key.verify(bytes.fromhex(sig_hex), message)
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, Exception):
except (InvalidSignature, ValueError):
return False
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
PQ_KEX_ALG = "ML-KEM-768"
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
priv = mlkem.MLKEM768PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
return priv, pub
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = pub.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
+2 -2
View File
@@ -187,8 +187,8 @@ def _print_version(ctx, param, value):
help="Browser profile alias to target (required when multiple browsers are active).",
)
@click.option(
"--remote", default=None, metavar="HOST:PORT",
help="Connect to a remote browser exposed via 'browser-cli serve'.",
"--remote", default=None, metavar="HOST[:PORT]",
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
)
@click.option(
"--key", default=None, metavar="PATH",
+104 -20
View File
@@ -10,8 +10,10 @@ Profile selection order:
"""
import json
import os
import re
import socket
import struct
import sys
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
@@ -19,6 +21,7 @@ from pathlib import Path
from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.registry import load_registry
try:
@@ -31,6 +34,39 @@ REGISTRY_PATH = registry_path()
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
_DEFAULT_REMOTE_PORT = 443
def _looks_like_domain(host: str) -> bool:
"""True if host looks like a domain name rather than an IP address or localhost."""
if host in {"localhost", "127.0.0.1", "::1"}:
return False
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
return False
return '.' in host and any(c.isalpha() for c in host)
def _normalize_endpoint(endpoint: str) -> str:
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
if not endpoint:
return endpoint
host, sep, port = endpoint.rpartition(":")
if sep and port == "443" and _looks_like_domain(host):
return host
return endpoint
def _resolve_connect_endpoint(endpoint: str) -> str:
"""Return host:port for TCP connection; domain without port defaults to :443."""
_, sep, _ = endpoint.rpartition(":")
if not sep:
if _looks_like_domain(endpoint):
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
raise BrowserNotConnected(
f"Invalid remote endpoint '{endpoint}': expected host:port"
)
return endpoint
class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available."""
@@ -66,13 +102,14 @@ def _load_remotes() -> dict[str, dict[str, str]]:
return {}
if not isinstance(data, dict):
return {}
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
# normalize keys so old entries stored as "domain:443" match current lookups
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def _is_valid_key_spec(s: str) -> bool:
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and "/" in s or Path(s).suffix in {".pem", ".key"})
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
def save_remote_key(endpoint: str, key_spec: str) -> None:
@@ -107,8 +144,11 @@ def key_for_remote(endpoint: str | None) -> str | None:
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
host, sep, port = endpoint.rpartition(":")
remote_name = host if sep and port == "8765" else endpoint
return f"{remote_name}:{display_name or profile_name}"
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
display_endpoint = host
else:
display_endpoint = endpoint # normalized domain (no port) or non-default port
return f"{display_endpoint}:{display_name or profile_name}"
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
@@ -238,12 +278,23 @@ def _load_private_key(key_path: "Path | str | None" = None):
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
host, _, port_str = endpoint.rpartition(":")
if not host or not port_str:
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(30)
sock.connect((host, int(port_str)))
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
port = int(port_str)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
# receive challenge
challenge_raw = _recv_all(sock)
@@ -268,17 +319,51 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
except (IndexError, ValueError):
pass
pq_shared_secret = None
if nonce_hex and private_key is not None:
from browser_cli.auth import sign, public_key_hex
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig"}}
sig = sign(private_key, nonce, clean_msg)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
if pq_shared_secret is not None:
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
msg = {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": public_key_hex(private_key),
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
return _recv_all(sock)
response = _recv_all(sock)
if response is not None and pq_shared_secret is not None:
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
def _auto_route_remote(endpoint: str, key=None) -> str | None:
@@ -299,6 +384,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
"""Send a command to the browser and return the response data."""
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
if remote_endpoint:
remote_endpoint = _normalize_endpoint(remote_endpoint)
remote_alias_target = None
if not remote_endpoint and requested_profile:
remote_alias_target = remote_target_for_alias(requested_profile)
@@ -327,22 +414,22 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
msg["_route"] = route_profile
else:
private_key = None
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
try:
if remote_endpoint:
response = _send_remote(remote_endpoint, msg, private_key)
elif is_windows():
payload = json.dumps(msg).encode("utf-8")
sock_path = _resolve_socket(profile)
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
payload = json.dumps(msg).encode("utf-8")
sock_path = _resolve_socket(profile)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
sock.sendall(struct.pack("<I", len(payload)) + payload)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
if remote_endpoint:
@@ -368,9 +455,6 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
return result.get("data")
_MAX_MSG_BYTES = 32 * 1024 * 1024
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
+40
View File
@@ -0,0 +1,40 @@
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
_console = Console()
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
+1 -12
View File
@@ -1,22 +1,11 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("cookies")
def cookies_group():
"""Manage browser cookies."""
+1 -12
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
from rich.table import Table
import json
@@ -7,17 +7,6 @@ import json
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("dom")
def dom_group():
"""Query and interact with page DOM elements."""
+1 -12
View File
@@ -3,7 +3,7 @@ import re
from html.parser import HTMLParser
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
from rich.table import Table
@@ -423,17 +423,6 @@ def _convert_html_to_markdown(html):
return _clean_markdown_output(markdown)
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("extract")
def extract_group():
"""Extract content from the active tab."""
+1 -36
View File
@@ -1,46 +1,11 @@
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
+1 -12
View File
@@ -1,21 +1,10 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
console = Console()
def _handle(command, args):
try:
return send_command(command, args)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("nav")
def nav_group():
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
+1 -12
View File
@@ -1,22 +1,11 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("page")
def page_group():
"""Inspect current page metadata."""
+2 -9
View File
@@ -1,6 +1,6 @@
import click
from urllib.parse import quote_plus
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
console = Console()
@@ -71,14 +71,7 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
def _cmd(query, bg, window, group):
terms = " ".join(query)
url = ENGINES[engine_key].format(query=quote_plus(terms))
try:
send_command("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
+95 -46
View File
@@ -1,25 +1,20 @@
import re, threading, secrets, socket, struct, click, json, sys, os
import re, threading, secrets, socket, struct, click, json, sys
from datetime import datetime
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version, get_installed_version
from browser_cli.compat import adapt_request, adapt_response
from rich.console import Console
from browser_cli.client import _recv_exact, _recv_all
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
_MAX_MSG_BYTES = 32 * 1024 * 1024
from rich.console import Console
from datetime import datetime
console = Console()
def _recv_exact(sock:socket.socket, n:int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed")
buf += chunk
return buf
def _framed_send(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
ts = datetime.now().strftime("%H:%M:%S")
@@ -30,21 +25,36 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
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, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
def _send_payload(data: bytes) -> None:
if response_secret is not None:
from browser_cli.auth import pq_encrypt
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
client_sock.sendall(struct.pack("<I", len(err)) + err)
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload) -> None:
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
try:
_send_payload(out)
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > _MAX_MSG_BYTES:
if msg_len > MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
@@ -58,25 +68,26 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_log(addr, "?", None, "ERROR", "invalid JSON")
return
msg_id = msg.get("id")
command = msg.get("command", "?")
# ── user-agent + version check ────────────────────────────────────────────
msg_id = msg.get("id")
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
_log(addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, command, None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
_log(addr, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
msg = adapt_auth(msg, client_ver)
command = msg.get("command", "?")
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
@@ -89,11 +100,46 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_send_error(msg_id, "unauthorized: untrusted public key")
_log(addr, command, None, "DENIED", "untrusted key")
return
pq_shared_secret = None
transport_encrypted = False
if pq_private_key is not None:
kex = msg.get("pq_kex") or {}
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
if pq_required:
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
_log(addr, command, None, "DENIED", "missing pq kex")
return
else:
try:
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
if "encrypted" in msg:
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
if not isinstance(decrypted_msg, dict):
raise ValueError("encrypted request is not a JSON object")
decrypted_msg["pubkey"] = pub
decrypted_msg["sig"] = sig
decrypted_msg["pq_kex"] = kex
msg = adapt_auth(decrypted_msg, client_ver)
msg_id = msg.get("id", msg_id)
command = msg.get("command", "?")
transport_encrypted = True
elif pq_required:
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
_log(addr, command, None, "DENIED", "missing pq transport")
return
except Exception:
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
_log(addr, command, None, "DENIED", "bad pq transport")
return
from browser_cli.auth import verify
if not verify(pub, bytes.fromhex(nonce), msg, sig):
if not verify(pub, bytes.fromhex(nonce), msg, sig, pq_shared_secret):
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
response_secret = pq_shared_secret if transport_encrypted else None
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
@@ -101,8 +147,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
data = json.dumps({"id": msg_id, "success": True, "data": targets}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_send_ok(msg_id, targets)
_log(addr, command, None, "OK")
return
@@ -113,8 +158,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
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)
_send_ok(msg_id, entries)
_log(addr, command, None, "OK")
return
@@ -127,20 +171,19 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
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")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
_send_error(msg_id, "invalid pubkey: expected 64 lowercase 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)
_send_ok(msg_id, {"added": added})
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent"}
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
@@ -158,20 +201,18 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
from multiprocessing.connection import Client as PipeClient
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp = pipe.recv_bytes()
resp = adapt_response(resp, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
_send_payload(resp_payload)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_header = _recv_exact(local, 4)
resp_len = struct.unpack("<I", resp_header)[0]
resp_payload = _recv_exact(local, resp_len)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
_send_payload(resp_payload)
resp_data = json.loads(resp_payload if not is_windows() else resp)
resp_data = json.loads(resp_payload)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
@@ -194,17 +235,25 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
else:
auth_keys = None
nonce = secrets.token_hex(32)
challenge = json.dumps({
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}).encode()
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = pq_kex_server_keypair()
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()}
challenge = json.dumps(challenge_msg).encode()
try:
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
_framed_send(client_sock, challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
finally:
_CONN_LIMIT.release()
+1 -36
View File
@@ -1,45 +1,10 @@
import click
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, BrowserNotConnected
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from rich.console import Console
console = Console()
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@click.group("session")
def session_group():
"""Save and restore browser sessions."""
+1 -12
View File
@@ -1,22 +1,11 @@
import json
import click
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.commands import _handle
from rich.console import Console
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("storage")
def storage_group():
"""Read and write the page's localStorage / sessionStorage."""
+1 -36
View File
@@ -1,48 +1,13 @@
import base64
import binascii
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
if not tabs:
console.print("[yellow]No tabs found[/yellow]")
+1 -36
View File
@@ -1,46 +1,11 @@
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows:
console.print("[yellow]No windows found[/yellow]")
-49
View File
@@ -1,49 +0,0 @@
"""
Stripe-style version compatibility layer for browser-cli serve.
When a behaviour-breaking change ships in a new server version, add one entry
to _COMPAT below:
("X.Y.Z", request_fn, response_fn)
- ``request_fn(msg: dict) -> dict``
Upgrade an incoming client message from a client older than X.Y.Z to the
current format before forwarding it to the native host.
- ``response_fn(resp: bytes, command: str) -> bytes``
Downgrade a native-host response to the format a client older than X.Y.Z
expects before sending it back.
Either function may be ``None`` when only one direction needs adapting.
Entries must stay in ascending version order. ``adapt_request`` walks forward
(oldest change first); ``adapt_response`` walks backward (newest change first)
so the transformations compose correctly.
Current baseline: 0.9.1 no shims needed yet.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("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 server 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)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a server 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)
return resp
+4
View File
@@ -0,0 +1,4 @@
from browser_cli.compat.commands import adapt_request, adapt_response
from browser_cli.compat.auth import adapt_auth
__all__ = ["adapt_auth", "adapt_request", "adapt_response"]
+44
View File
@@ -0,0 +1,44 @@
"""
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)
Entries must stay in ascending version order.
"""
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") == "browser-cli.auth.trust":
args = msg.get("args") or {}
trust_pk = args.get("pubkey")
if isinstance(trust_pk, str) and trust_pk:
changed["args"] = {**args, "pubkey": trust_pk.lower()}
return {**msg, **changed} if changed else msg
# ── registry ──────────────────────────────────────────────────────────────────
_AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
("0.9.3", _auth_0_9_3),
]
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)
return msg
+43
View File
@@ -0,0 +1,43 @@
"""
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)
- request_fn(msg: dict) -> dict or None
- response_fn(resp: bytes, command: str) -> bytes or None
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.
"""
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),
]
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)
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)
return resp
+3
View File
@@ -19,6 +19,7 @@ from multiprocessing.connection import Listener
from pathlib import Path
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.registry import update_registry
SOCKET_PATH: str = "" # set after hello handshake
@@ -278,6 +279,8 @@ def _recv_all(conn: socket.socket) -> bytes | None:
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
return None
return _recv_exact(conn, msg_len)
+1
View File
@@ -1,6 +1,7 @@
from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
def parse_version(v: str) -> tuple[int, ...]:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.9.2",
"version": "0.9.8",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+103 -45
View File
@@ -1,23 +1,64 @@
// @ts-nocheck
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
import { executeScript, getActiveTab, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl } from '../core';
import { contentDispatch } from './injected';
export async function domOp(funcName, args) {
const tab = await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
function fallbackForErrorPageDomOp(funcName, tab) {
switch (funcName) {
case "domExists":
return false;
case "domQuery":
case "domAttr":
case "domText":
case "extractLinks":
case "extractImages":
return [];
case "extractText":
case "extractMarkdown":
return "";
case "pageInfo":
return {
title: tab.title || "",
url: tab.url || tab.pendingUrl || "",
readyState: "error",
lang: null,
meta: {},
};
default:
return undefined;
}
}
export async function domOp(funcName, args = {}) {
const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab();
const tabUrl = tab.url || tab.pendingUrl || "";
if (isBrowserErrorUrl(tabUrl)) {
const fallback = fallbackForErrorPageDomOp(funcName, tab);
if (fallback !== undefined) return fallback;
}
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
try {
const results = await executeScript({
target: { tabId: tab.id },
func: contentDispatch,
args: [funcName, args],
});
return results[0]?.result;
} catch (e) {
if (isErrorPageScriptError(e)) {
const fallback = fallbackForErrorPageDomOp(funcName, tab);
if (fallback !== undefined) return fallback;
}
throw e;
}
const results = await executeScript({
target: { tabId: tab.id },
func: contentDispatch,
args: [funcName, args],
});
return results[0]?.result;
}
export async function domEval({ code, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (!isScriptableUrl(tabUrl) || isBrowserErrorUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
@@ -30,26 +71,36 @@ export async function domEval({ code, tabId } = {}) {
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (isBrowserErrorUrl(tabUrl)) {
if (hidden) return { selector, found: false };
throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`);
}
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
const el = document.querySelector(sel);
if (hid) return !el || el.offsetParent === null;
if (!el) return false;
if (vis) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
return true;
},
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
try {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
const el = document.querySelector(sel);
if (hid) return !el || el.offsetParent === null;
if (!el) return false;
if (vis) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
return true;
},
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
} catch (e) {
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
if (!isErrorPageScriptError(e)) throw e;
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
@@ -57,26 +108,33 @@ export async function domWaitFor({ selector, timeout = 10000, visible = false, h
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (isBrowserErrorUrl(tabUrl)) {
throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`);
}
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
const el = document.querySelector(sel);
if (!el) return null;
if (a) return el.getAttribute(a) ?? el[a] ?? null;
return el.value !== undefined ? el.value : el.textContent.trim();
},
args: [selector, attr || null],
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
try {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
const el = document.querySelector(sel);
if (!el) return null;
if (a) return el.getAttribute(a) ?? el[a] ?? null;
return el.value !== undefined ? el.value : el.textContent.trim();
},
args: [selector, attr || null],
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
} catch (e) {
if (!isErrorPageScriptError(e)) throw e;
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
}
+5 -1
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
import { getActiveTab, getAliases, resolveGroupId, tabInfo } from '../core';
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
let windowId;
if (explicitWindowId != null) {
@@ -77,6 +77,10 @@ export async function navWait({ tabId, timeout = 30000, readyState = "complete"
const interval = 200;
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const currentUrl = t.url || t.pendingUrl || "";
if (isBrowserErrorUrl(currentUrl)) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
}
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
return tabInfo(t);
}
+25 -7
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
import { executeScript, getActiveTab, getAliases, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
export async function tabsList() {
const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases();
@@ -102,8 +102,12 @@ export async function tabsQuery({ search }) {
export async function tabsHtml({ tabId }) {
for (let i = 0; i < 3; i++) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) {
throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (isBrowserErrorUrl(tabUrl)) {
return "";
}
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`);
}
try {
const results = await executeScript({
@@ -112,7 +116,8 @@ export async function tabsHtml({ tabId }) {
});
return results[0]?.result || "";
} catch (e) {
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page");
if (isErrorPageScriptError(e)) return "";
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id"));
if (i < 2 && transient) {
await new Promise(r => setTimeout(r, 300));
continue;
@@ -191,13 +196,26 @@ export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
let lastUrl = tab.url || tab.pendingUrl || "";
let lastStatus = tab.status || "unknown";
const matches = (url) => {
regex.lastIndex = 0;
return Boolean(url && regex.test(url));
};
if (matches(lastUrl)) return tabInfo(tab);
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const url = t.url || t.pendingUrl || "";
if (regex.test(url)) return tabInfo(t);
lastUrl = t.url || t.pendingUrl || "";
lastStatus = t.status || "unknown";
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
if (isBrowserErrorUrl(t.url || "")) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
}
export async function tabsMute({ tabId }) {
+28 -1
View File
@@ -5,12 +5,39 @@ export async function getProfileAlias() {
return profileAlias || "default";
}
export function isBrowserErrorUrl(url) {
const value = String(url || "").toLowerCase();
return value.startsWith("chrome-error://") ||
value.startsWith("edge-error://") ||
value.startsWith("brave-error://") ||
value.startsWith("about:neterror") ||
value.startsWith("about:certerror") ||
value.startsWith("about:blocked") ||
value.startsWith("about:tabcrashed");
}
export function isErrorPageScriptError(error) {
const message = String(error?.message || error || "").toLowerCase();
return message.includes("error page") ||
message.includes("chrome-error://") ||
message.includes("edge-error://") ||
message.includes("brave-error://") ||
message.includes("about:neterror") ||
message.includes("about:certerror") ||
message.includes("about:tabcrashed");
}
export function isTransientScriptError(error) {
const message = String(error?.message || error || "");
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
}
export async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await chrome.scripting.executeScript(options);
} catch (e) {
if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) {
if (i < retries - 1 && isTransientScriptError(e)) {
await new Promise(r => setTimeout(r, 300));
continue;
}
+2 -2
View File
@@ -1,11 +1,11 @@
[project]
name = "browser-cli"
version = "0.9.2"
version = "0.9.8"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
"click>=8",
"cryptography>=42",
"cryptography>=48",
"rich>=13",
]
+206
View File
@@ -0,0 +1,206 @@
import json
import tempfile
from pathlib import Path
import pytest
from browser_cli.auth import (
add_authorized_key,
canonical_payload,
generate_keypair,
load_authorized_keys,
load_authorized_keys_with_names,
load_private_key,
new_nonce,
pq_decrypt,
pq_encrypt,
pq_kex_client_encapsulate,
pq_kex_server_decapsulate,
pq_kex_server_keypair,
sign,
verify,
)
from browser_cli.client import _is_valid_key_spec
class TestGenerateKeypair:
def test_returns_pem_and_hex(self):
pem, pub_hex = generate_keypair()
assert pem.startswith(b"-----BEGIN PRIVATE KEY-----")
assert len(pub_hex) == 64
def test_each_call_unique(self):
_, pub1 = generate_keypair()
_, pub2 = generate_keypair()
assert pub1 != pub2
class TestCanonicalPayload:
def test_strips_auth_protocol_fields(self):
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def", "pq_kex": {"alg": "ML-KEM-768"}}
data = json.loads(canonical_payload(msg))
assert "pubkey" not in data
assert "sig" not in data
assert "pq_kex" not in data
def test_keys_sorted(self):
msg = {"z": 1, "a": 2, "m": 3}
payload = canonical_payload(msg).decode()
assert payload.index('"a"') < payload.index('"m"') < payload.index('"z"')
def test_deterministic(self):
msg = {"b": 2, "a": 1}
assert canonical_payload(msg) == canonical_payload(msg)
@pytest.fixture()
def keypair(tmp_path):
pem, pub_hex = generate_keypair()
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
return priv, pub_hex
class TestSignVerify:
def test_valid_signature_verifies(self, keypair):
priv, pub_hex = keypair
nonce = bytes.fromhex(new_nonce())
msg = {"command": "tabs.list", "id": "uuid-1", "args": {}}
sig = sign(priv, nonce, msg).hex()
assert verify(pub_hex, nonce, msg, sig) is True
def test_tampered_sig_fails(self, keypair):
priv, pub_hex = keypair
nonce = bytes.fromhex(new_nonce())
msg = {"command": "tabs.list", "id": "x"}
sign(priv, nonce, msg)
assert verify(pub_hex, nonce, msg, "00" * 64) is False
def test_wrong_pubkey_fails(self, keypair):
priv, _ = keypair
_, other_pub = generate_keypair()
nonce = bytes.fromhex(new_nonce())
msg = {"command": "tabs.list"}
sig = sign(priv, nonce, msg).hex()
assert verify(other_pub, nonce, msg, sig) is False
def test_wrong_nonce_fails(self, keypair):
priv, pub_hex = keypair
nonce = bytes.fromhex(new_nonce())
msg = {"command": "tabs.list"}
sig = sign(priv, nonce, msg).hex()
other_nonce = bytes.fromhex(new_nonce())
assert verify(pub_hex, other_nonce, msg, sig) is False
def test_post_quantum_shared_secret_is_bound_to_signature(self, keypair):
priv, pub_hex = keypair
nonce = bytes.fromhex(new_nonce())
msg = {"command": "tabs.list", "pq_kex": {"alg": "ML-KEM-768", "ciphertext": "abcd"}}
sig = sign(priv, nonce, msg, b"shared-secret").hex()
assert verify(pub_hex, nonce, msg, sig, b"shared-secret") is True
assert verify(pub_hex, nonce, msg, sig, b"other-secret") is False
assert verify(pub_hex, nonce, msg, sig) is False
def test_garbage_pub_hex_returns_false_not_exception(self):
assert verify("not-hex!!!!", b"nonce", {}, "00" * 64) is False
def test_truncated_sig_hex_returns_false_not_exception(self, keypair):
_, pub_hex = keypair
assert verify(pub_hex, b"nonce", {}, "aabb") is False
def test_wrong_length_pubkey_returns_false_not_exception(self):
assert verify("aabbcc", b"nonce", {}, "00" * 64) is False
class TestPostQuantumKex:
def test_mlkem_roundtrip_when_backend_supports_it(self):
keypair = pq_kex_server_keypair()
if keypair is None:
pytest.skip("ML-KEM backend not available")
priv, pub = keypair
ciphertext_hex, client_secret = pq_kex_client_encapsulate(pub.hex())
server_secret = pq_kex_server_decapsulate(priv, ciphertext_hex)
assert server_secret == client_secret
assert len(server_secret) == 32
def test_pq_transport_encrypt_decrypt_roundtrip(self):
secret = b"s" * 32
plaintext = b'{"command":"tabs.list"}'
envelope = pq_encrypt(secret, "request", plaintext)
assert envelope["alg"] == "ML-KEM-768+ChaCha20Poly1305"
assert plaintext.hex() not in envelope["ciphertext"]
assert pq_decrypt(secret, "request", envelope) == plaintext
def test_pq_transport_direction_is_bound(self):
secret = b"s" * 32
envelope = pq_encrypt(secret, "request", b"payload")
with pytest.raises(Exception):
pq_decrypt(secret, "response", envelope)
class TestAuthorizedKeys:
def test_add_and_load(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
assert add_authorized_key(path, pub, "alice") is True
assert pub in load_authorized_keys(path)
def test_add_duplicate_returns_false(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
add_authorized_key(path, pub)
assert add_authorized_key(path, pub) is False
def test_load_with_names(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub1 = generate_keypair()
_, pub2 = generate_keypair()
add_authorized_key(path, pub1, "alice")
add_authorized_key(path, pub2)
entries = load_authorized_keys_with_names(path)
assert (pub1, "alice") in entries
assert (pub2, "") in entries
def test_ignores_comment_lines(self, tmp_path):
path = tmp_path / "authorized_keys"
path.write_text("# this is a comment\n")
assert load_authorized_keys(path) == []
def test_returns_empty_for_missing_file(self, tmp_path):
assert load_authorized_keys(tmp_path / "nofile") == []
class TestIsValidKeySpec:
def test_agent_bare(self):
assert _is_valid_key_spec("agent") is True
def test_agent_with_selector(self):
assert _is_valid_key_spec("agent:cardno:000012345678") is True
def test_absolute_pem_path(self):
assert _is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
def test_dot_key_extension(self):
assert _is_valid_key_spec("/tmp/mykey.key") is True
def test_angled_bracket_pem_rejected(self):
# regression: operator precedence bug allowed "<garbage>.pem" to pass
assert _is_valid_key_spec("<garbage>.pem") is False
def test_angled_bracket_key_rejected(self):
assert _is_valid_key_spec("<garbage>.key") is False
def test_serialized_object_rejected(self):
assert _is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
def test_empty_string_rejected(self):
assert _is_valid_key_spec("") is False
def test_bare_filename_no_slash_no_ext_rejected(self):
assert _is_valid_key_spec("mykey") is False
+28 -28
View File
@@ -262,12 +262,12 @@ def test_tabs_list_multi_browser_shows_browser_column():
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
with patch(
"browser_cli.commands.tabs.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
@@ -278,13 +278,13 @@ def test_tabs_list_multi_browser_shows_browser_column():
def test_tabs_list_with_remote_uses_only_remote_targets():
with patch(
"browser_cli.commands.tabs.active_browser_targets",
"browser_cli.commands.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.commands.tabs.remote_browser_targets",
"browser_cli.commands.remote_browser_targets",
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
), patch(
"browser_cli.commands.tabs.send_command",
"browser_cli.commands.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
@@ -297,13 +297,13 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
with patch(
"browser_cli.commands.tabs.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch(
"browser_cli.commands.tabs.send_command",
"browser_cli.commands.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
@@ -322,12 +322,12 @@ def test_tabs_count_multi_browser_shows_total():
return counts[profile]
with patch(
"browser_cli.commands.tabs.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "count", "github"])
assert result.exit_code == 0
@@ -344,12 +344,12 @@ def test_group_count_multi_browser_shows_total():
return counts[profile]
with patch(
"browser_cli.commands.groups.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command):
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["groups", "count"])
assert result.exit_code == 0
@@ -360,7 +360,7 @@ def test_group_count_multi_browser_shows_total():
def test_group_list_leaves_unnamed_group_cell_empty():
with patch(
"browser_cli.commands.groups.send_command",
"browser_cli.commands.send_command",
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
):
result = CliRunner().invoke(main, ["groups", "list"])
@@ -372,7 +372,7 @@ def test_group_list_leaves_unnamed_group_cell_empty():
def test_tabs_move_accepts_right_short_alias():
with patch("browser_cli.commands.tabs.send_command") as send_command:
with patch("browser_cli.commands.send_command") as send_command:
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
assert result.exit_code == 0
@@ -384,7 +384,7 @@ def test_tabs_move_accepts_right_short_alias():
def test_groups_move_accepts_left_short_alias():
with patch("browser_cli.commands.groups.send_command") as send_command:
with patch("browser_cli.commands.send_command") as send_command:
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
assert result.exit_code == 0
@@ -399,12 +399,12 @@ def test_windows_list_multi_browser_shows_browser_column():
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
with patch(
"browser_cli.commands.windows.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.windows.send_command", side_effect=fake_send_command):
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["windows", "list"])
assert result.exit_code == 0
@@ -420,12 +420,12 @@ def test_session_list_multi_browser_shows_browser_column():
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
with patch(
"browser_cli.commands.session.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.session.send_command", side_effect=fake_send_command):
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["session", "list"])
assert result.exit_code == 0
@@ -438,13 +438,13 @@ def test_session_list_multi_browser_shows_browser_column():
def test_session_list_with_explicit_browser_does_not_show_browser_column():
with patch(
"browser_cli.commands.session.active_browser_targets",
"browser_cli.commands.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch(
"browser_cli.commands.session.send_command",
"browser_cli.commands.send_command",
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
) as send_command:
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
@@ -455,7 +455,7 @@ def test_session_list_with_explicit_browser_does_not_show_browser_column():
def test_windows_open_passes_url():
with patch("browser_cli.commands.windows.send_command", return_value={"id": 7}) as send_command:
with patch("browser_cli.commands.send_command", return_value={"id": 7}) as send_command:
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
assert result.exit_code == 0
@@ -463,20 +463,20 @@ def test_windows_open_passes_url():
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
def test_extract_markdown_command():
with patch("browser_cli.commands.extract.send_command", return_value="# Title") as send_command:
with patch("browser_cli.commands.send_command", return_value="# Title") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown"])
assert result.exit_code == 0
assert result.output == "# Title\n"
send_command.assert_called_once_with("extract.markdown", {"selector": None})
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
def test_extract_markdown_command_with_selector():
with patch("browser_cli.commands.extract.send_command", return_value="## Post") as send_command:
with patch("browser_cli.commands.send_command", return_value="## Post") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
assert result.exit_code == 0
assert result.output == "## Post\n"
send_command.assert_called_once_with("extract.markdown", {"selector": "article"})
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
@@ -561,7 +561,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
"```"
)
with patch("browser_cli.commands.extract.send_command", return_value=raw):
with patch("browser_cli.commands.send_command", return_value=raw):
result = CliRunner().invoke(main, ["extract", "markdown"])
assert result.exit_code == 0
@@ -639,8 +639,8 @@ def test_tabs_list_multi_browser_queries_remote_target():
remote=endpoint,
)
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
"browser_cli.commands.tabs.send_command",
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
"browser_cli.commands.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["tabs", "list"])
+117
View File
@@ -6,6 +6,9 @@ import pytest
from browser_cli.client import (
BrowserNotConnected,
BrowserTarget,
_looks_like_domain,
_normalize_endpoint,
_resolve_connect_endpoint,
_resolve_socket,
active_browser_targets,
display_browser_name,
@@ -238,6 +241,120 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].remote == endpoint
def test_looks_like_domain():
assert _looks_like_domain("browsercli.yiprawr.dev") is True
assert _looks_like_domain("browser-host.example") is True
assert _looks_like_domain("sub.domain.org") is True
assert _looks_like_domain("localhost") is False
assert _looks_like_domain("127.0.0.1") is False
assert _looks_like_domain("192.168.1.100") is False
assert _looks_like_domain("host") is False # no dot
def test_normalize_endpoint_strips_443_for_domains():
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev"
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev"
assert _normalize_endpoint("192.168.1.1:443") == "192.168.1.1:443" # IP: keep port
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
def test_resolve_connect_endpoint_adds_443_for_domain():
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443"
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443"
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765"
assert _resolve_connect_endpoint("host:8765") == "host:8765"
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
with pytest.raises(BrowserNotConnected, match="expected host:port"):
_resolve_connect_endpoint("localhost")
def test_send_command_normalizes_domain_port_443(monkeypatch):
"""--remote domain:443 is normalized; _send_remote gets the portless domain."""
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
sent_to = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
sent_to["endpoint"] = endpoint
return json.dumps({"success": True, "data": "ok"}).encode()
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443")
assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
"""--remote domain (no port) is treated as :443."""
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
sent_to = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
sent_to["endpoint"] = endpoint
return json.dumps({"success": True, "data": "ok"}).encode()
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev")
assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev"
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
remotes_path = tmp_path / "remotes.json"
endpoint = "browsercli.yiprawr.dev"
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
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, key=None):
return [{"profile": "automatisation", "displayName": "automatisation"}]
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
assert targets[0].remote == endpoint
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
"""Old remotes.json with :443 still displays cleanly without the port."""
remotes_path = tmp_path / "remotes.json"
endpoint = "browsercli.yiprawr.dev:443" # old format
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
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, key=None):
return [{"profile": "automatisation", "displayName": "automatisation"}]
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
import json as _json
@@ -0,0 +1,37 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_extension_retries_error_page_script_injection_before_failing():
core = (ROOT / "extension" / "src" / "core.ts").read_text()
assert "isBrowserErrorUrl" in core
assert "isErrorPageScriptError" in core
assert "chrome-error://" in core
assert "edge-error://" in core
assert "brave-error://" in core
assert "about:neterror" in core
assert "about:certerror" in core
assert "isTransientScriptError(e)" in core
def test_read_only_dom_commands_have_error_page_fallbacks():
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
assert "fallbackForErrorPageDomOp" in dom
assert 'case "domExists":' in dom
assert "return false;" in dom
assert 'case "domQuery":' in dom
assert 'case "extractText":' in dom
assert "isBrowserErrorUrl(tabUrl)" in dom
assert "isErrorPageScriptError(e)" in dom
def test_navigation_and_tabs_report_browser_error_pages():
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text()
assert "lastUrl" in tabs
assert "lastStatus" in tabs
assert "showing an error page" in tabs
assert "last URL:" in tabs
assert "isBrowserErrorUrl" in navigation
assert "showing an error page while waiting for load" in navigation
+213
View File
@@ -0,0 +1,213 @@
"""Remote protocol compatibility matrix for post-quantum transport.
These tests exercise the wire-level combinations that matter for mixed
browser-cli versions without requiring a real browser. The native-host lookup is
mocked so successful auth/transport reaches the proxy layer and then returns the
expected "browser not connected" error.
"""
import contextlib
import io
import json
import os
import socket
import struct
import threading
import pytest
from browser_cli.auth import (
generate_keypair,
load_private_key,
pq_decrypt,
pq_encrypt,
pq_kex_client_encapsulate,
pq_kex_server_decapsulate,
pq_kex_server_keypair,
sign,
)
from browser_cli.client import BrowserNotConnected, send_command
from browser_cli.commands.serve import _handle_client
def _send_framed(sock: socket.socket, msg: dict) -> None:
payload = json.dumps(msg).encode("utf-8")
sock.sendall(struct.pack("<I", len(payload)) + payload)
def _recv_framed(sock: socket.socket) -> dict:
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise ConnectionError("socket closed before response header")
raw_len += chunk
msg_len = struct.unpack("<I", raw_len)[0]
data = b""
while len(data) < msg_len:
chunk = sock.recv(msg_len - len(data))
if not chunk:
raise ConnectionError("socket closed mid-response")
data += chunk
return json.loads(data)
@pytest.fixture()
def auth_material(tmp_path):
pem, pub = generate_keypair()
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
auth_path = tmp_path / "authorized_keys"
auth_path.write_text(pub + "\n", encoding="utf-8")
return key_path, auth_path, load_private_key(key_path), pub
@pytest.fixture(autouse=True)
def no_browser(monkeypatch):
def _raise_no_browser(*_args, **_kwargs):
raise BrowserNotConnected("no browser")
monkeypatch.setattr("browser_cli.client._resolve_socket", _raise_no_browser)
def _connect(auth_keys_path):
client, server = socket.socketpair()
thread = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
daemon=True,
)
thread.start()
challenge = _recv_framed(client)
return client, thread, challenge
def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]:
if "pq_kex" not in challenge:
pytest.skip("ML-KEM backend not available")
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
clean_msg = {
**command_msg,
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
}
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
if not encrypted:
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
envelope = {
"id": clean_msg["id"],
"user_agent": clean_msg["user_agent"],
"pubkey": pub,
"sig": sig,
"pq_kex": clean_msg["pq_kex"],
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
}
return envelope, shared_secret
def _assert_browser_not_connected(resp: dict) -> None:
assert resp.get("success") is False
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
def test_real_mlkem_primitive_roundtrip():
keypair = pq_kex_server_keypair()
if keypair is None:
pytest.skip("ML-KEM backend not available")
private_key, public_key = keypair
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
assert server_secret == client_secret
@pytest.mark.parametrize(
("client_version", "encrypted", "expect_encrypted_response"),
[
("0.9.3", False, False), # legacy client stays compatible
("0.9.5", True, True), # current client must use encrypted transport
],
)
def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response):
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
if selected_version and selected_version != client_version:
pytest.skip(f"compat matrix selected {selected_version}")
_key_path, auth_path, priv, pub = auth_material
client, thread, challenge = _connect(auth_path)
msg = {
"id": f"tabs-{client_version}",
"command": "tabs.list",
"args": {},
"user_agent": f"browser-cli/{client_version}",
}
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
_send_framed(client, wire_msg)
resp = _recv_framed(client)
if expect_encrypted_response:
assert set(resp) == {"encrypted"}
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
else:
assert "encrypted" not in resp
_assert_browser_not_connected(resp)
client.close()
thread.join(timeout=2)
def test_current_client_plaintext_transport_is_rejected(auth_material):
_key_path, auth_path, priv, pub = auth_material
client, thread, challenge = _connect(auth_path)
msg = {
"id": "new-plain",
"command": "tabs.list",
"args": {},
"user_agent": "browser-cli/0.9.5",
}
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
_send_framed(client, wire_msg)
resp = _recv_framed(client)
assert resp.get("success") is False
assert "encrypted transport" in resp.get("error", "").lower()
client.close()
thread.join(timeout=2)
def test_send_command_uses_encrypted_remote_transport(auth_material):
key_path, auth_path, _priv, _pub = auth_material
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
host, port = server.getsockname()
def _accept_once():
conn, addr = server.accept()
_handle_client(conn, addr, None, auth_path)
server.close()
thread = threading.Thread(target=_accept_once, daemon=True)
thread.start()
with pytest.raises(RuntimeError, match="browser|connected"):
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
thread.join(timeout=2)
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch):
key_path, auth_path, _priv, _pub = auth_material
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
host, port = server.getsockname()
def _accept_once():
conn, addr = server.accept()
_handle_client(conn, addr, None, auth_path)
server.close()
thread = threading.Thread(target=_accept_once, daemon=True)
thread.start()
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
with pytest.raises(RuntimeError, match="browser|connected"):
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
assert "not using a post-quantum key exchange" in stderr.getvalue()
thread.join(timeout=2)
+373
View File
@@ -0,0 +1,373 @@
"""Unit tests for the TCP serve layer (challenge-response auth, framing, rejection paths)."""
import json
import socket
import struct
import threading
import pytest
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
from browser_cli.client import BrowserNotConnected
from browser_cli.commands.serve import _handle_client
FAKE_UA = "browser-cli/0.9.3"
# ── helpers ────────────────────────────────────────────────────────────────────
def _send_framed(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
def _recv_framed(sock: socket.socket) -> dict:
raw = b""
while len(raw) < 4:
chunk = sock.recv(4 - len(raw))
if not chunk:
raise ConnectionError("socket closed before response header")
raw += chunk
n = struct.unpack("<I", raw)[0]
data = b""
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("socket closed mid-response")
data += chunk
return json.loads(data)
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
t = threading.Thread(
target=_handle_client,
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path),
daemon=True,
)
t.start()
return t
def _pair():
return socket.socketpair()
def _mock_no_browser(*_args, **_kwargs):
raise BrowserNotConnected("no browser")
# ── challenge frame ────────────────────────────────────────────────────────────
class TestChallenge:
def test_challenge_sent_on_connect(self):
client, server = _pair()
t = _spawn(server, None)
challenge = _recv_framed(client)
assert challenge["type"] == "challenge"
assert "nonce" in challenge
client.close()
t.join(timeout=2)
def test_challenge_includes_version_fields(self):
client, server = _pair()
t = _spawn(server, None)
challenge = _recv_framed(client)
assert "server_version" in challenge
assert "min_client_version" in challenge
client.close()
t.join(timeout=2)
def test_challenge_advertises_post_quantum_kex_when_available(self, tmp_path, monkeypatch):
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
client, server = _pair()
t = _spawn(server, path)
challenge = _recv_framed(client)
assert challenge["pq_kex"] == {"alg": "ML-KEM-768", "public_key": b"fake-public".hex()}
client.close()
t.join(timeout=2)
# ── rejection paths ────────────────────────────────────────────────────────────
class TestRejection:
def _connect(self, auth_keys_path):
client, server = _pair()
t = _spawn(server, auth_keys_path)
challenge = _recv_framed(client)
return client, t, challenge
def test_bad_user_agent_rejected(self):
client, t, _ = self._connect(None)
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "curl/7.88"}
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "forbidden" in resp["error"].lower() or "client" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_missing_pubkey_sig_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
client, t, _ = self._connect(path)
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "unauthorized" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_untrusted_pubkey_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, trusted_pub = generate_keypair()
path.write_text(trusted_pub + "\n")
pem, untrusted_pub = generate_keypair()
key_path = tmp_path / "other.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, t, challenge = self._connect(path)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": untrusted_pub}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "untrusted" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_bad_signature_rejected(self, tmp_path):
path = tmp_path / "authorized_keys"
_, pub = generate_keypair()
path.write_text(pub + "\n")
client, t, _ = self._connect(path)
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "signature" in resp["error"].lower() or "invalid" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_missing_post_quantum_kex_rejected_when_required(self, tmp_path, monkeypatch):
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
priv_path = tmp_path / "client.pem"
priv_path.write_bytes(pem)
priv = load_private_key(priv_path)
client, t, challenge = self._connect(path)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5", "pubkey": pub}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "post-quantum" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_oversized_message_rejected(self):
client, server = _pair()
t = _spawn(server, None)
_recv_framed(client) # consume challenge
client.sendall(struct.pack("<I", 33 * 1024 * 1024))
resp = _recv_framed(client)
assert resp["success"] is False
assert "too large" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_invalid_json_rejected(self):
client, server = _pair()
t = _spawn(server, None)
_recv_framed(client) # consume challenge
bad = b"this is not json {"
_send_framed(client, bad)
resp = _recv_framed(client)
assert resp["success"] is False
client.close()
t.join(timeout=2)
# ── auth success paths ─────────────────────────────────────────────────────────
class TestAuthSuccess:
def test_valid_auth_reaches_proxy(self, tmp_path, monkeypatch):
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, path),
daemon=True,
)
t.start()
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert resp["success"] is False
assert "unauthorized" not in resp["error"].lower()
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
client.close()
t.join(timeout=2)
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair() # pub is lowercase hex
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
client, server = _pair()
t = _spawn(server, path)
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
# old client sends uppercase pubkey
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
msg["sig"] = sign(priv, nonce, msg).hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert "unauthorized" not in resp.get("error", "").lower()
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, path),
daemon=True,
)
t.start()
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
msg = {
"id": "x",
"command": "tabs.list",
"args": {},
"user_agent": FAKE_UA,
"pubkey": pub,
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
}
msg["sig"] = sign(priv, nonce, msg, b"pq-secret").hex()
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert "unauthorized" not in resp.get("error", "").lower()
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
def test_post_quantum_encrypted_transport_reaches_proxy(self, tmp_path, monkeypatch):
"""New clients encrypt the command payload and receive encrypted responses."""
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + "\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, path),
daemon=True,
)
t.start()
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
clean_msg = {
"id": "x",
"command": "tabs.list",
"args": {},
"user_agent": "browser-cli/0.9.5",
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": "cafe"},
}
sig = sign(priv, nonce, clean_msg, b"pq-secret").hex()
envelope = {
"id": "x",
"user_agent": "browser-cli/0.9.5",
"pubkey": pub,
"sig": sig,
"pq_kex": clean_msg["pq_kex"],
"encrypted": pq_encrypt(b"pq-secret", "request", json.dumps(clean_msg).encode()),
}
_send_framed(client, json.dumps(envelope).encode())
encrypted_resp = _recv_framed(client)
assert "encrypted" in encrypted_resp
resp = json.loads(pq_decrypt(b"pq-secret", "response", encrypted_resp["encrypted"]))
assert "unauthorized" not in resp.get("error", "").lower()
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, None),
daemon=True,
)
t.start()
_recv_framed(client) # challenge
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
_send_framed(client, json.dumps(msg).encode())
resp = _recv_framed(client)
assert "unauthorized" not in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
Generated
+52 -52
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.9.2"
version = "0.9.8"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -20,7 +20,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=42" },
{ name = "cryptography", specifier = ">=48" },
{ name = "rich", specifier = ">=13" },
]
@@ -132,62 +132,62 @@ wheels = [
[[package]]
name = "cryptography"
version = "47.0.0"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" },
{ url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" },
{ url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" },
{ url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" },
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
]
[[package]]