From 43d94932fb18147cd942f26986f51fc094983759 Mon Sep 17 00:00:00 2001 From: alexveley Date: Tue, 22 Sep 2026 06:54:43 -0400 Subject: [PATCH] Replace shared bearer token with registered Ed25519 client keys. --- .gitignore | 2 + AGENTS.md | 87 +++++++----- README.md | 19 ++- fab_auth.py | 220 ++++++++++++++++++++++++++++++ host/firefox_agent_bridge_host.py | 66 +++++---- requirements.txt | 1 + tools/client.py | 47 +++++-- tools/install-native-host.ps1 | 17 +-- tools/register_client.py | 53 +++++++ 9 files changed, 412 insertions(+), 100 deletions(-) create mode 100644 fab_auth.py create mode 100644 requirements.txt create mode 100644 tools/register_client.py diff --git a/.gitignore b/.gitignore index 5c7aaa2..be111ca 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .DS_Store host/com.easygoingaming.firefox_agent_bridge.json +*.pem +web-ext-artifacts/ diff --git a/AGENTS.md b/AGENTS.md index a88b1d5..97e0eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,31 +5,61 @@ Use this when a script or coding agent needs to **create, update, move, or delet ## Preconditions 1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1` -2. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install) -3. Firefox **running** (the host process is started by the extension) +2. A **registered client key** for this tool (see Auth). The host has only the public half. +3. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install) +4. Firefox **running** (the host process is started by the extension) If `http://127.0.0.1:17634/health` fails, the host is not up — open Firefox and confirm the extension is loaded. If `/health` works but `/v1/ready` fails, the stdio pipe is down; reload the extension. ## Auth -Token file (created by the installer): +The loopback port is **not** an open local API. Each caller is a named Ed25519 client. -`%LOCALAPPDATA%\firefox-agent-bridge\token` +Register once per tool (human or agent operator does this; do not write the private key under `%LOCALAPPDATA%\firefox-agent-bridge`): -Send it on every request except `/health`: - -``` -Authorization: Bearer +```text +python tools/register_client.py add --name cursor-agent --write-key %USERPROFILE%\.fab\cursor-agent.json ``` -Override with env `FAB_TOKEN` or `FAB_URL` (default `http://127.0.0.1:17634`). +Give tooling **only** that key file: + +```text +set FAB_KEY_FILE=%USERPROFILE%\.fab\cursor-agent.json +``` + +or `python tools/client.py --key PATH …`. + +The host stores public keys in `%LOCALAPPDATA%\firefox-agent-bridge\clients.json`. Revoke with `python tools/register_client.py revoke NAME_OR_ID`. List with `… list`. + +Every request except `/health` must be signed: + +``` +Authorization: FAB-ED25519 id= +X-FAB-Timestamp: +X-FAB-Nonce: +X-FAB-Signature: +``` + +Canonical message (UTF-8, newline-separated): + +```text +v1 + + + + + + +``` + +Skew allowance is 90 seconds. Nonces cannot be reused. `tools/client.py` builds this for you. + +Do not use a shared bearer token. Do not commit private key bundles. Do not paste PEM material into chat. ## Preferred caller -From this repo: - ```text -python tools/client.py METHOD [ARGS_JSON] +python tools/client.py --key KEYFILE METHOD [ARGS_JSON] ``` `ARGS_JSON` is a JSON **array** matching the WebExtension function arguments. @@ -37,22 +67,11 @@ python tools/client.py METHOD [ARGS_JSON] Examples: ```text -python tools/client.py meta.methods -python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" -python tools/client.py bookmarks.getChildren "[\"FOLDER_ID\"]" -python tools/client.py bookmarks.create "[{\"parentId\":\"FOLDER_ID\",\"title\":\"NPM\",\"url\":\"http://10.132.99.80:81/\"}]" -python tools/client.py bookmarks.remove "[\"BOOKMARK_ID\"]" -python tools/client.py bookmarks.removeTree "[\"FOLDER_ID\"]" +python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json meta.methods +python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json bookmarks.search "[{\"title\":\"10.132.x.x\"}]" ``` -HTTP equivalent: - -```http -POST /v1/call -Content-Type: application/json - -{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]} -``` +HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"method":"bookmarks.search","args":[{"title":"10.132.x.x"}]}`. ## Allowlisted methods (v0.1) @@ -74,11 +93,11 @@ Content-Type: application/json Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension. -REST aliases (same auth): +REST aliases (same signature): | HTTP | Maps to | |------|---------| -| `GET /health` | host process only (no token) | +| `GET /health` | host process only (no key) | | `GET /v1/ready` | `meta.ping` | | `GET /v1/methods` | `meta.methods` | | `GET /v1/bookmarks/tree` | `bookmarks.getTree` | @@ -98,7 +117,7 @@ REST aliases (same auth): 3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder. 4. `bookmarks.create` each new item with `parentId` set. -`examples/replace_named_folder.py` does exactly that. It is an example, not a sync service. +`examples/replace_named_folder.py` does exactly that (`FAB_KEY_FILE` must be set). It is an example, not a sync service. You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified`). Operate on a named subfolder (toolbar / menu / a folder the user already created). @@ -107,7 +126,9 @@ You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified | Symptom | Cause | What to do | |---------|--------|------------| | Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on | -| 401 | Missing/wrong bearer | Read the token file | +| 401 `signed FAB-ED25519 client required` | Old bearer token or unsigned curl | Use `tools/client.py` and a registered key | +| 401 `unknown client id` | Key revoked or host has no `clients.json` | `register_client.py list` / `add` | +| 401 `bad signature` / `replayed nonce` / skew | Wrong key, reused request, or clock drift | New request; check `FAB_KEY_FILE` | | 502 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` | | 502 timeout | Extension died mid-call | Reload the add-on | | Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted | @@ -117,9 +138,9 @@ You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified - The extension does **not** listen on `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher. - The native host manifest `allowed_extensions` is pinned to `firefox-agent-bridge@easygoingaming.com`. A different add-on cannot `connectNative` to this host. -- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected (pages cannot drive the API). `Host` must be `127.0.0.1:` or `localhost:`. -- A same-user process that can read `%LOCALAPPDATA%\firefox-agent-bridge\token` has the same power as this API. That is intentional for local agents. Do not copy the token into git, chat, or a world-readable file. -- Another add-on that already has the `bookmarks` permission does not need this bridge — Firefox already gave it Places. This project does not increase that add-on's capability. +- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected. `Host` must be loopback. +- Local processes **without** a registered private key cannot edit bookmarks through this port. The public store is useless for impersonation. +- Another add-on that already has the `bookmarks` permission does not need this bridge. - Do not add `tabs`, `history`, ``, or `onMessageExternal` without a new threat review. ## What this project is not diff --git a/README.md b/README.md index 31ee06f..af6f9a2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite` |-------|------| | `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) | | `host/` | Native host: stdio to Firefox, HTTP to scripts | -| `tools/client.py` | Stdlib Python caller | +| `tools/client.py` | Signed Python caller (`FAB_KEY_FILE`) | | `examples/replace_named_folder.py` | Sample "replace this folder" script | ## Install (this workstation) @@ -27,9 +27,16 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite` powershell -NoProfile -File tools\install-native-host.ps1 ``` - That writes `%LOCALAPPDATA%\firefox-agent-bridge\token` and + That registers `HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`. + Then create a client key **outside** that state directory and give the file to tooling: + + ```powershell + python tools\register_client.py add --name cursor-agent --write-key $HOME\.fab\cursor-agent.json + $env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json" + ``` + 2. Load the extension. Firefox Release will not keep an unsigned add-on across restarts: - `about:debugging#/runtime/this-firefox` @@ -43,8 +50,8 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite` ## Call it ```powershell -$token = Get-Content $env:LOCALAPPDATA\firefox-agent-bridge\token -Raw -curl.exe -s -H "Authorization: Bearer $token" http://127.0.0.1:17634/v1/methods +$env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json" +python tools/client.py meta.methods python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" ``` @@ -53,9 +60,9 @@ See [AGENTS.md](AGENTS.md) for the method list, HTTP surface, and failure modes. ## Security - Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback. -- Every mutating call (and most reads) needs `Authorization: Bearer `. +- Calls (except `/health`) must be signed by a **registered Ed25519 client**. The host keeps public keys only. - Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, tabs, or `onMessageExternal` in v0.1. -- Another add-on with `bookmarks` already has Places; this bridge does not give it a new path in. Same-user processes that steal the token do — that is the agent contract. +- Another add-on with `bookmarks` already has Places; this bridge does not give it a new path in. ## License diff --git a/fab_auth.py b/fab_auth.py new file mode 100644 index 0000000..384d3c1 --- /dev/null +++ b/fab_auth.py @@ -0,0 +1,220 @@ +"""Ed25519 client registration and request signing. + +The host stores public keys only. Private keys are written where the user +says and passed into tooling — never next to the native host state. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, + load_pem_private_key, + load_pem_public_key, +) + +STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" +CLIENTS_PATH = STATE_DIR / "clients.json" +SKEW_SECONDS = 90 +NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") + + +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_clients() -> list[dict[str, Any]]: + if not CLIENTS_PATH.exists(): + return [] + data = json.loads(CLIENTS_PATH.read_text(encoding="utf-8")) + return list(data.get("clients") or []) + + +def save_clients(clients: list[dict[str, Any]]) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + CLIENTS_PATH.write_text( + json.dumps({"clients": clients}, indent=2) + "\n", + encoding="utf-8", + ) + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def _unb64(text: str) -> bytes: + return base64.b64decode(text.encode("ascii")) + + +def body_hash(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + + +def canonical( + client_id: str, + timestamp: str, + nonce: str, + http_method: str, + path: str, + raw_body: bytes, +) -> bytes: + return "\n".join( + [ + "v1", + client_id, + timestamp, + nonce, + http_method.upper(), + path, + body_hash(raw_body), + ] + ).encode("utf-8") + + +def register_client(name: str, key_path: Path) -> dict[str, str]: + if not NAME_RE.match(name): + raise ValueError("name must be 1-64 chars of A-Za-z0-9._-") + key_path = key_path.expanduser().resolve() + if STATE_DIR in key_path.parents or key_path.parent == STATE_DIR: + raise ValueError( + f"refusing to write a private key under {STATE_DIR} — pick a path you will give to tooling" + ) + clients = load_clients() + if any(c.get("name") == name for c in clients): + raise ValueError(f"client already registered: {name}") + private = Ed25519PrivateKey.generate() + public = private.public_key() + client_id = uuid.uuid4().hex + pub_pem = public.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii") + priv_pem = private.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode("ascii") + clients.append( + { + "id": client_id, + "name": name, + "public_key_pem": pub_pem, + "created": _now(), + } + ) + save_clients(clients) + key_path.parent.mkdir(parents=True, exist_ok=True) + if key_path.exists(): + raise ValueError(f"key file already exists: {key_path}") + bundle = { + "id": client_id, + "name": name, + "private_key_pem": priv_pem, + "public_key_pem": pub_pem, + } + key_path.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8") + try: + os.chmod(key_path, 0o600) + except OSError: + pass + return {"id": client_id, "name": name, "key_file": str(key_path)} + + +def revoke_client(name_or_id: str) -> str: + clients = load_clients() + kept = [c for c in clients if c.get("id") != name_or_id and c.get("name") != name_or_id] + if len(kept) == len(clients): + raise ValueError(f"no client {name_or_id!r}") + save_clients(kept) + return name_or_id + + +def load_key_bundle(path: Path) -> dict[str, str]: + data = json.loads(path.expanduser().read_text(encoding="utf-8")) + if not data.get("id") or not data.get("private_key_pem"): + raise ValueError("key file must contain id and private_key_pem") + return data + + +def sign_headers( + bundle: dict[str, str], + http_method: str, + path: str, + raw_body: bytes, +) -> dict[str, str]: + private = load_pem_private_key(bundle["private_key_pem"].encode("ascii"), password=None) + if not isinstance(private, Ed25519PrivateKey): + raise ValueError("key file is not an Ed25519 private key") + timestamp = str(int(time.time())) + nonce = uuid.uuid4().hex + message = canonical(bundle["id"], timestamp, nonce, http_method, path, raw_body) + signature = _b64(private.sign(message)) + return { + "Authorization": f"FAB-ED25519 id={bundle['id']}", + "X-FAB-Timestamp": timestamp, + "X-FAB-Nonce": nonce, + "X-FAB-Signature": signature, + } + + +class ReplayCache: + def __init__(self, limit: int = 2048) -> None: + self.limit = limit + self._seen: dict[str, float] = {} + + def accept(self, nonce: str, now: float) -> bool: + cutoff = now - SKEW_SECONDS + stale = [key for key, ts in self._seen.items() if ts < cutoff] + for key in stale: + del self._seen[key] + if nonce in self._seen: + return False + self._seen[nonce] = now + if len(self._seen) > self.limit: + oldest = sorted(self._seen, key=self._seen.get)[: len(self._seen) - self.limit] + for key in oldest: + del self._seen[key] + return True + + +def verify_request( + headers: dict[str, str], + http_method: str, + path: str, + raw_body: bytes, + replay: ReplayCache, +) -> dict[str, Any]: + auth = headers.get("Authorization") or headers.get("authorization") or "" + match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth.strip()) + if not match: + raise PermissionError("signed FAB-ED25519 client required") + client_id = match.group(1) + timestamp = (headers.get("X-FAB-Timestamp") or headers.get("x-fab-timestamp") or "").strip() + nonce = (headers.get("X-FAB-Nonce") or headers.get("x-fab-nonce") or "").strip() + signature = (headers.get("X-FAB-Signature") or headers.get("x-fab-signature") or "").strip() + if not timestamp.isdigit() or not nonce or not signature: + raise PermissionError("missing signature headers") + now = time.time() + if abs(now - int(timestamp)) > SKEW_SECONDS: + raise PermissionError("timestamp outside allowed skew") + if not replay.accept(nonce, now): + raise PermissionError("replayed nonce") + client = next((c for c in load_clients() if c.get("id") == client_id), None) + if not client: + raise PermissionError("unknown client id") + public = load_pem_public_key(client["public_key_pem"].encode("ascii")) + if not isinstance(public, Ed25519PublicKey): + raise PermissionError("stored key is not Ed25519") + message = canonical(client_id, timestamp, nonce, http_method, path, raw_body) + try: + public.verify(_unb64(signature), message) + except InvalidSignature as exc: + raise PermissionError("bad signature") from exc + return client diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py index 2a9f704..1c06aed 100644 --- a/host/firefox_agent_bridge_host.py +++ b/host/firefox_agent_bridge_host.py @@ -2,7 +2,6 @@ """Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio.""" from __future__ import annotations -import hmac import json import os import struct @@ -14,6 +13,10 @@ from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from fab_auth import CLIENTS_PATH, ReplayCache, verify_request # noqa: E402 + if sys.platform == "win32": import msvcrt @@ -25,10 +28,10 @@ PORT = int(os.environ.get("FAB_PORT", "17634")) MAX_BODY = 256 * 1024 ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" -TOKEN_PATH = STATE_DIR / "token" STDIN_LOCK = threading.Lock() PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {} PENDING_LOCK = threading.Lock() +REPLAY = ReplayCache() def log(msg: str) -> None: @@ -36,15 +39,6 @@ def log(msg: str) -> None: sys.stderr.flush() -def read_token() -> str: - env = os.environ.get("FAB_TOKEN") - if env: - return env.strip() - if TOKEN_PATH.exists(): - return TOKEN_PATH.read_text(encoding="utf-8").strip() - return "" - - def send_to_extension(payload: dict[str, Any]) -> None: raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") with STDIN_LOCK: @@ -106,27 +100,25 @@ class Handler(BaseHTTPRequestHandler): return False return True - def _auth_ok(self) -> bool: - if not self._gate_ok(): - return False - token = read_token() - if not token: - self._send(500, {"ok": False, "error": "bridge token missing; run install-native-host.ps1"}) - return False - header = self.headers.get("Authorization", "") - got = header[7:].strip() if header.lower().startswith("bearer ") else "" - if not hmac.compare_digest(got, token): - self._send(401, {"ok": False, "error": "missing or invalid bearer token"}) - return False - return True - - def _json_body(self) -> dict[str, Any]: + def _read_raw(self) -> bytes: length = int(self.headers.get("Content-Length") or "0") if length > MAX_BODY: raise ValueError("request body too large") if length <= 0: - return {} - raw = self.rfile.read(length) + return b"" + return self.rfile.read(length) + + def _authorize(self, http_method: str, path: str, raw_body: bytes) -> bool: + if not self._gate_ok(): + return False + try: + verify_request({k: v for k, v in self.headers.items()}, http_method, path, raw_body, REPLAY) + except PermissionError as exc: + self._send(401, {"ok": False, "error": str(exc)}) + return False + return True + + def _parse_json(self, raw: bytes) -> dict[str, Any]: if not raw: return {} data = json.loads(raw.decode("utf-8")) @@ -141,7 +133,7 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT}) return - if not self._auth_ok(): + if not self._authorize("GET", parsed.path, b""): return qs = parse_qs(parsed.query) try: @@ -167,11 +159,16 @@ class Handler(BaseHTTPRequestHandler): self._send(502, {"ok": False, "error": str(exc)}) def do_POST(self) -> None: - if not self._auth_ok(): - return parsed = urlparse(self.path) try: - body = self._json_body() + raw = self._read_raw() + except ValueError as exc: + self._send(400, {"ok": False, "error": str(exc)}) + return + if not self._authorize("POST", parsed.path, raw): + return + try: + body = self._parse_json(raw) except ValueError as exc: self._send(400, {"ok": False, "error": str(exc)}) return @@ -225,9 +222,8 @@ def stdin_loop() -> None: def main() -> int: STATE_DIR.mkdir(parents=True, exist_ok=True) - token = read_token() - if not token: - log(f"no token at {TOKEN_PATH}; run tools/install-native-host.ps1") + if not CLIENTS_PATH.exists(): + log(f"no registered clients at {CLIENTS_PATH}; run tools/register_client.py add") threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() server = ThreadingHTTPServer((HOST, PORT), Handler) log(f"listening on http://{HOST}:{PORT}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65b9689 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +cryptography>=42 diff --git a/tools/client.py b/tools/client.py index 63b51ef..9f16c8a 100644 --- a/tools/client.py +++ b/tools/client.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Minimal client for Firefox Agent Bridge. Stdlib only.""" +"""Call Firefox Agent Bridge with a registered Ed25519 client key.""" from __future__ import annotations import argparse @@ -11,33 +11,49 @@ import urllib.request from pathlib import Path from typing import Any +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from fab_auth import load_key_bundle, sign_headers # noqa: E402 + DEFAULT_BASE = os.environ.get("FAB_URL", "http://127.0.0.1:17634") -TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" / "token" -def token() -> str: - env = os.environ.get("FAB_TOKEN") +def key_path() -> Path: + env = os.environ.get("FAB_KEY_FILE") if env: - return env.strip() - if TOKEN_PATH.exists(): - return TOKEN_PATH.read_text(encoding="utf-8").strip() - raise SystemExit(f"no token: set FAB_TOKEN or create {TOKEN_PATH}") + return Path(env) + raise SystemExit( + "no client key: set FAB_KEY_FILE or pass --key " + "(python tools/register_client.py add --name NAME --write-key PATH)" + ) -def call(method: str, args: list[Any] | None = None, base: str = DEFAULT_BASE) -> Any: +def call( + method: str, + args: list[Any] | None = None, + base: str = DEFAULT_BASE, + key_file: Path | None = None, +) -> Any: payload = json.dumps({"method": method, "args": args or []}).encode("utf-8") + path = "/v1/call" + bundle = load_key_bundle(key_file or key_path()) + headers = { + "Content-Type": "application/json", + **sign_headers(bundle, "POST", path, payload), + } req = urllib.request.Request( - base.rstrip("/") + "/v1/call", + base.rstrip("/") + path, data=payload, method="POST", - headers={ - "Authorization": f"Bearer {token()}", - "Content-Type": "application/json", - }, + headers=headers, ) try: with urllib.request.urlopen(req, timeout=20) as resp: body = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"bridge HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise SystemExit( f"bridge unreachable at {base} ({exc}). " @@ -53,11 +69,12 @@ def main() -> int: parser.add_argument("method", help="e.g. bookmarks.search or meta.methods") parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments") parser.add_argument("--url", default=DEFAULT_BASE) + parser.add_argument("--key", type=Path, help="client key bundle (or FAB_KEY_FILE)") ns = parser.parse_args() args = json.loads(ns.args_json) if not isinstance(args, list): raise SystemExit("args_json must be a JSON array") - print(json.dumps(call(ns.method, args, base=ns.url), indent=2)) + print(json.dumps(call(ns.method, args, base=ns.url, key_file=ns.key), indent=2)) return 0 diff --git a/tools/install-native-host.ps1 b/tools/install-native-host.ps1 index 01cbc23..d683c01 100644 --- a/tools/install-native-host.ps1 +++ b/tools/install-native-host.ps1 @@ -16,14 +16,9 @@ if (-not (Test-Path $CmdPath)) { } New-Item -ItemType Directory -Force -Path $StateDir | Out-Null -if (-not (Test-Path $TokenPath) -or -not (Get-Content -Raw $TokenPath).Trim()) { - $bytes = New-Object byte[] 32 - [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) - $token = -join ($bytes | ForEach-Object { $_.ToString("x2") }) - [System.IO.File]::WriteAllText($TokenPath, $token) - Write-Host "wrote $TokenPath" -} else { - Write-Host "kept existing token at $TokenPath" +if (Test-Path $TokenPath) { + Remove-Item -Force $TokenPath + Write-Host "removed leftover shared token $TokenPath" } $manifest = @{ @@ -42,6 +37,6 @@ Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath Write-Host "registered $regPath" Write-Host "" -Write-Host "Next: in Firefox open about:debugging#/runtime/this-firefox" -Write-Host "Load Temporary Add-on and pick extension\manifest.json" -Write-Host "Token is read from $TokenPath (Authorization: Bearer ...)" +Write-Host "Register a client key for tooling (private key stays out of $StateDir):" +Write-Host " python tools\register_client.py add --name cursor-agent --write-key `$HOME\.fab\cursor-agent.json" +Write-Host "Then load the extension and set FAB_KEY_FILE to that path." diff --git a/tools/register_client.py b/tools/register_client.py new file mode 100644 index 0000000..19d984d --- /dev/null +++ b/tools/register_client.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Register, list, or revoke Ed25519 clients for Firefox Agent Bridge.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from fab_auth import CLIENTS_PATH, load_clients, register_client, revoke_client # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manage Firefox Agent Bridge clients") + sub = parser.add_subparsers(dest="cmd", required=True) + + add = sub.add_parser("add", help="generate a keypair and register the public half") + add.add_argument("--name", required=True, help="label, e.g. cursor-agent") + add.add_argument( + "--write-key", + required=True, + type=Path, + help="private key bundle path for tooling (must not be under LocalAppData\\firefox-agent-bridge)", + ) + + sub.add_parser("list", help="show registered public clients") + + drop = sub.add_parser("revoke", help="drop a client by name or id") + drop.add_argument("name_or_id") + + ns = parser.parse_args() + if ns.cmd == "add": + info = register_client(ns.name, ns.write_key) + print(json.dumps(info, indent=2)) + print(f"give {info['key_file']} to tooling via FAB_KEY_FILE or --key", file=sys.stderr) + return 0 + if ns.cmd == "list": + rows = [ + {"id": c.get("id"), "name": c.get("name"), "created": c.get("created")} + for c in load_clients() + ] + print(json.dumps({"store": str(CLIENTS_PATH), "clients": rows}, indent=2)) + return 0 + revoke_client(ns.name_or_id) + print(json.dumps({"revoked": ns.name_or_id})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())