Replace shared bearer token with registered Ed25519 client keys.
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
cryptography>=42
|
||||||
+32
-15
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user