Replace shared bearer token with registered Ed25519 client keys.

This commit is contained in:
alexveley
2026-09-22 06:54:43 -04:00
parent 535b3ce837
commit 43d94932fb
9 changed files with 412 additions and 100 deletions
+2
View File
@@ -2,3 +2,5 @@ __pycache__/
*.pyc *.pyc
.DS_Store .DS_Store
host/com.easygoingaming.firefox_agent_bridge.json host/com.easygoingaming.firefox_agent_bridge.json
*.pem
web-ext-artifacts/
+54 -33
View File
@@ -5,31 +5,61 @@ Use this when a script or coding agent needs to **create, update, move, or delet
## Preconditions ## Preconditions
1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1` 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) 2. A **registered client key** for this tool (see Auth). The host has only the public half.
3. Firefox **running** (the host process is started by the extension) 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. 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 ## 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`: ```text
python tools/register_client.py add --name cursor-agent --write-key %USERPROFILE%\.fab\cursor-agent.json
```
Authorization: Bearer <token>
``` ```
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=<client_id>
X-FAB-Timestamp: <unix seconds>
X-FAB-Nonce: <unique hex>
X-FAB-Signature: <base64 Ed25519 of canonical message>
```
Canonical message (UTF-8, newline-separated):
```text
v1
<client_id>
<timestamp>
<nonce>
<HTTP_METHOD>
<path>
<sha256 hex of raw body>
```
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 ## Preferred caller
From this repo:
```text ```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. `ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
@@ -37,22 +67,11 @@ python tools/client.py METHOD [ARGS_JSON]
Examples: Examples:
```text ```text
python tools/client.py meta.methods python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json meta.methods
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json 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\"]"
``` ```
HTTP equivalent: HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"method":"bookmarks.search","args":[{"title":"10.132.x.x"}]}`.
```http
POST /v1/call
Content-Type: application/json
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
```
## Allowlisted methods (v0.1) ## 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. 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 | | HTTP | Maps to |
|------|---------| |------|---------|
| `GET /health` | host process only (no token) | | `GET /health` | host process only (no key) |
| `GET /v1/ready` | `meta.ping` | | `GET /v1/ready` | `meta.ping` |
| `GET /v1/methods` | `meta.methods` | | `GET /v1/methods` | `meta.methods` |
| `GET /v1/bookmarks/tree` | `bookmarks.getTree` | | `GET /v1/bookmarks/tree` | `bookmarks.getTree` |
@@ -98,7 +117,7 @@ REST aliases (same auth):
3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder. 3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder.
4. `bookmarks.create` each new item with `parentId` set. 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). 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 | | Symptom | Cause | What to do |
|---------|--------|------------| |---------|--------|------------|
| Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on | | 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 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` |
| 502 timeout | Extension died mid-call | Reload the add-on | | 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 | | 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 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. - 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:<port>` or `localhost:<port>`. - HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected. `Host` must be loopback.
- 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. - 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 — Firefox already gave it Places. This project does not increase that add-on's capability. - Another add-on that already has the `bookmarks` permission does not need this bridge.
- Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review. - Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
## What this project is not ## What this project is not
+13 -6
View File
@@ -16,7 +16,7 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite`
|-------|------| |-------|------|
| `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) | | `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) |
| `host/` | Native host: stdio to Firefox, HTTP to scripts | | `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 | | `examples/replace_named_folder.py` | Sample "replace this folder" script |
## Install (this workstation) ## 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 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`. `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: 2. Load the extension. Firefox Release will not keep an unsigned add-on across restarts:
- `about:debugging#/runtime/this-firefox` - `about:debugging#/runtime/this-firefox`
@@ -43,8 +50,8 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite`
## Call it ## Call it
```powershell ```powershell
$token = Get-Content $env:LOCALAPPDATA\firefox-agent-bridge\token -Raw $env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json"
curl.exe -s -H "Authorization: Bearer $token" http://127.0.0.1:17634/v1/methods python tools/client.py meta.methods
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" 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 ## Security
- Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback. - Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback.
- Every mutating call (and most reads) needs `Authorization: Bearer <token>`. - 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. - 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 ## License
+220
View File
@@ -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
+31 -35
View File
@@ -2,7 +2,6 @@
"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio.""" """Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio."""
from __future__ import annotations from __future__ import annotations
import hmac
import json import json
import os import os
import struct import struct
@@ -14,6 +13,10 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse 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": if sys.platform == "win32":
import msvcrt import msvcrt
@@ -25,10 +28,10 @@ PORT = int(os.environ.get("FAB_PORT", "17634"))
MAX_BODY = 256 * 1024 MAX_BODY = 256 * 1024
ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"}
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
TOKEN_PATH = STATE_DIR / "token"
STDIN_LOCK = threading.Lock() STDIN_LOCK = threading.Lock()
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {} PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
PENDING_LOCK = threading.Lock() PENDING_LOCK = threading.Lock()
REPLAY = ReplayCache()
def log(msg: str) -> None: def log(msg: str) -> None:
@@ -36,15 +39,6 @@ def log(msg: str) -> None:
sys.stderr.flush() 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: def send_to_extension(payload: dict[str, Any]) -> None:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
with STDIN_LOCK: with STDIN_LOCK:
@@ -106,27 +100,25 @@ class Handler(BaseHTTPRequestHandler):
return False return False
return True return True
def _auth_ok(self) -> bool: def _read_raw(self) -> bytes:
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]:
length = int(self.headers.get("Content-Length") or "0") length = int(self.headers.get("Content-Length") or "0")
if length > MAX_BODY: if length > MAX_BODY:
raise ValueError("request body too large") raise ValueError("request body too large")
if length <= 0: if length <= 0:
return {} return b""
raw = self.rfile.read(length) 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: if not raw:
return {} return {}
data = json.loads(raw.decode("utf-8")) data = json.loads(raw.decode("utf-8"))
@@ -141,7 +133,7 @@ class Handler(BaseHTTPRequestHandler):
return return
self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT}) self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT})
return return
if not self._auth_ok(): if not self._authorize("GET", parsed.path, b""):
return return
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
try: try:
@@ -167,11 +159,16 @@ class Handler(BaseHTTPRequestHandler):
self._send(502, {"ok": False, "error": str(exc)}) self._send(502, {"ok": False, "error": str(exc)})
def do_POST(self) -> None: def do_POST(self) -> None:
if not self._auth_ok():
return
parsed = urlparse(self.path) parsed = urlparse(self.path)
try: 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: except ValueError as exc:
self._send(400, {"ok": False, "error": str(exc)}) self._send(400, {"ok": False, "error": str(exc)})
return return
@@ -225,9 +222,8 @@ def stdin_loop() -> None:
def main() -> int: def main() -> int:
STATE_DIR.mkdir(parents=True, exist_ok=True) STATE_DIR.mkdir(parents=True, exist_ok=True)
token = read_token() if not CLIENTS_PATH.exists():
if not token: log(f"no registered clients at {CLIENTS_PATH}; run tools/register_client.py add")
log(f"no token at {TOKEN_PATH}; run tools/install-native-host.ps1")
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
log(f"listening on http://{HOST}:{PORT}") log(f"listening on http://{HOST}:{PORT}")
+1
View File
@@ -0,0 +1 @@
cryptography>=42
+32 -15
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import argparse
@@ -11,33 +11,49 @@ import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any 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") 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: def key_path() -> Path:
env = os.environ.get("FAB_TOKEN") env = os.environ.get("FAB_KEY_FILE")
if env: if env:
return env.strip() return Path(env)
if TOKEN_PATH.exists(): raise SystemExit(
return TOKEN_PATH.read_text(encoding="utf-8").strip() "no client key: set FAB_KEY_FILE or pass --key "
raise SystemExit(f"no token: set FAB_TOKEN or create {TOKEN_PATH}") "(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") 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( req = urllib.request.Request(
base.rstrip("/") + "/v1/call", base.rstrip("/") + path,
data=payload, data=payload,
method="POST", method="POST",
headers={ headers=headers,
"Authorization": f"Bearer {token()}",
"Content-Type": "application/json",
},
) )
try: try:
with urllib.request.urlopen(req, timeout=20) as resp: with urllib.request.urlopen(req, timeout=20) as resp:
body = json.loads(resp.read().decode("utf-8")) 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: except urllib.error.URLError as exc:
raise SystemExit( raise SystemExit(
f"bridge unreachable at {base} ({exc}). " 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("method", help="e.g. bookmarks.search or meta.methods")
parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments") parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments")
parser.add_argument("--url", default=DEFAULT_BASE) 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() ns = parser.parse_args()
args = json.loads(ns.args_json) args = json.loads(ns.args_json)
if not isinstance(args, list): if not isinstance(args, list):
raise SystemExit("args_json must be a JSON array") 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 return 0
+6 -11
View File
@@ -16,14 +16,9 @@ if (-not (Test-Path $CmdPath)) {
} }
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
if (-not (Test-Path $TokenPath) -or -not (Get-Content -Raw $TokenPath).Trim()) { if (Test-Path $TokenPath) {
$bytes = New-Object byte[] 32 Remove-Item -Force $TokenPath
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) Write-Host "removed leftover shared token $TokenPath"
$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"
} }
$manifest = @{ $manifest = @{
@@ -42,6 +37,6 @@ Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath
Write-Host "registered $regPath" Write-Host "registered $regPath"
Write-Host "" Write-Host ""
Write-Host "Next: in Firefox open about:debugging#/runtime/this-firefox" Write-Host "Register a client key for tooling (private key stays out of $StateDir):"
Write-Host "Load Temporary Add-on and pick extension\manifest.json" Write-Host " python tools\register_client.py add --name cursor-agent --write-key `$HOME\.fab\cursor-agent.json"
Write-Host "Token is read from $TokenPath (Authorization: Bearer ...)" Write-Host "Then load the extension and set FAB_KEY_FILE to that path."
+53
View File
@@ -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())