Replace Ed25519 with hashed API tokens and an in-Firefox client manager.

This commit is contained in:
alexveley
2026-09-22 07:24:35 -04:00
parent 55f7e863dc
commit 9242c01015
16 changed files with 487 additions and 339 deletions
+43 -79
View File
@@ -5,75 +5,59 @@ 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. 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)
2. Add-on loaded (temporary via `about:debugging`, or a signed install)
3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret
4. Firefox **running**
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. If `/health` works but calls 401, you do not have a current `FAB_TOKEN`.
## Auth
The loopback port is **not** an open local API. Each caller is a named Ed25519 client.
This is a normal API token, not an SSH key.
Register once per tool (human or agent operator does this; do not write the private key under `%LOCALAPPDATA%\firefox-agent-bridge`):
1. In Firefox, open the Agent Bridge toolbar icon → **Manage clients**.
2. Name the client (e.g. `cursor-agent`) and click **Generate secret**.
3. Copy the `fab_…` value **once**. Store it however you already store secrets: env var, Vaultwarden, `.env`, CI secret, Cursor env.
4. Give tooling only that string:
```text
python tools/register_client.py add --name cursor-agent --write-key %USERPROFILE%\.fab\cursor-agent.json
set FAB_TOKEN=fab_…
python tools/client.py meta.methods
```
Give tooling **only** that key file:
`FAB_TOKEN_FILE` pointing at a file that contains only the token is also fine. `--token` on the CLI overrides both.
The add-on stores **SHA-256(token)** only. Revoke from the same page. A local process that never received the secret cannot call the API.
CLI fallback (same store, prints the token once):
```text
set FAB_KEY_FILE=%USERPROFILE%\.fab\cursor-agent.json
python tools/register_client.py add --name cursor-agent
```
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
```text
python tools/client.py --key KEYFILE METHOD [ARGS_JSON]
python tools/client.py METHOD [ARGS_JSON]
```
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
Examples:
```text
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\"}]"
python tools/client.py bookmarks.search "[{\"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"}]}`.
HTTP:
## Allowlisted methods (v0.1)
```http
POST /v1/call
Authorization: Bearer fab_
Content-Type: application/json
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
```
## Allowlisted methods (v0.2)
| Method | Args | Notes |
|--------|------|--------|
@@ -93,58 +77,38 @@ HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"me
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
REST aliases (same signature):
| HTTP | Maps to |
|------|---------|
| `GET /health` | host process only (no key) |
| `GET /v1/ready` | `meta.ping` |
| `GET /v1/methods` | `meta.methods` |
| `GET /v1/bookmarks/tree` | `bookmarks.getTree` |
| `GET /v1/bookmarks/{id}` | `bookmarks.get` |
| `POST /v1/bookmarks/search` | body is the query object |
| `POST /v1/bookmarks/create` | body is `CreateDetails` |
| `POST /v1/bookmarks/update` | `{id, changes}` |
| `POST /v1/bookmarks/move` | `{id, destination}` |
| `POST /v1/bookmarks/remove` | `{id}` |
| `POST /v1/bookmarks/remove-tree` | `{id}` |
| `POST /v1/call` | `{method, args}` |
REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
## Typical folder replace
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url` (that is the folder). Fail if zero or many.
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url`. Fail if zero or many.
2. `bookmarks.getChildren` on that id.
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 (`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).
`examples/replace_named_folder.py` does that (`FAB_TOKEN` must be set).
## Failure modes
| Symptom | Cause | What to do |
|---------|--------|------------|
| Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on |
| 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 |
| Native host not found | Installer not run | `tools/install-native-host.ps1` |
| Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host |
| 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
| 502 `method not allowed` | Typo or API not in `ALLOWED` | `meta.methods` |
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
## Security boundaries
- 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. `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.
- No `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
- Native host `allowed_extensions` is pinned to this add-on's gecko id.
- HTTP is `127.0.0.1` only; browser `Origin` headers are rejected.
- Secrets are ordinary Bearer tokens. Do not write them under `%LOCALAPPDATA%\firefox-agent-bridge`.
- Another add-on with the `bookmarks` permission already has Places.
- Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
## What this project is not
- Not a bidirectional sync implementation
- Not a general Firefox remote-control surface (no tabs, history, cookies, native file access)
- Not a general Firefox remote-control surface
- Not a reason to keep editing `places.sqlite` on a live profile
+21 -36
View File
@@ -1,8 +1,8 @@
# Firefox Agent Bridge
A small Firefox extension that exposes an **allowlisted** WebExtension API to local scripts and agents.
A small Firefox add-on that exposes an **allowlisted** WebExtension API to local scripts and agents — after you issue them a secret from inside Firefox.
This is **not** a bookmark sync engine. Firefox already has `browser.bookmarks`. The extension is a bridge: while Firefox is open, a script on the same machine can call those functions over `http://127.0.0.1:17634`.
This is **not** a bookmark sync engine. Firefox already has `browser.bookmarks`. The add-on is a bridge: while Firefox is open, a script on the same machine can call those functions over `http://127.0.0.1:17634` with a Bearer token.
Agents: start at [AGENTS.md](AGENTS.md).
@@ -10,16 +10,7 @@ Agents: start at [AGENTS.md](AGENTS.md).
Firefox will not let an outside process talk to Places. Editing `places.sqlite` while the browser is running loses deletes. The supported path is `browser.bookmarks` inside an extension, plus [native messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging) so a localhost HTTP host can reach that extension.
## Pieces
| Piece | Role |
|-------|------|
| `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) |
| `host/` | Native host: stdio to Firefox, HTTP to scripts |
| `tools/client.py` | Signed Python caller (`FAB_KEY_FILE`) |
| `examples/replace_named_folder.py` | Sample "replace this folder" script |
## Install (this workstation)
## Setup
1. Register the native host (once):
@@ -27,42 +18,36 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite`
powershell -NoProfile -File tools\install-native-host.ps1
```
That registers
`HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`.
2. Load the add-on (`about:debugging` temporary, or a signed `.xpi` — [docs/signing.md](docs/signing.md)).
Then create a client key **outside** that state directory and give the file to tooling:
3. Click the toolbar icon → **Manage clients** → name a client → **Generate secret**. Copy the `fab_…` token into whatever secret store you already use.
```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`
- **Load Temporary Add-on**
- pick `extension\manifest.json`
Permanent install: Mozilla-signed `.xpi` — [docs/signing.md](docs/signing.md). Start with `--channel=unlisted`.
3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON.
4. Leave Firefox open. `GET http://127.0.0.1:17634/health` should return JSON.
## Call it
```powershell
$env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json"
$env:FAB_TOKEN = "fab_…" # the value from the Manage clients modal
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"}]'
```
See [AGENTS.md](AGENTS.md) for the method list, HTTP surface, and failure modes.
`curl` works the same: `Authorization: Bearer fab_…`.
## Security
- Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback.
- 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.
- Binds **127.0.0.1** only. Browser `Origin` headers are rejected.
- Each caller is an issued token. The add-on stores a hash, not the secret.
- Only `bookmarks.*` (plus `meta.*`) in `extension/background.js`. No history, cookies, tabs, or `onMessageExternal`.
## Pieces
| Piece | Role |
|-------|------|
| `extension/` | Add-on, popup, Manage clients page |
| `host/` | Native host: stdio to Firefox, HTTP to scripts |
| `tools/client.py` | Bearer caller (`FAB_TOKEN`) |
| `examples/replace_named_folder.py` | Sample folder replace |
## License
+1 -1
View File
@@ -18,7 +18,7 @@ Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even whe
| **unlisted** | Signed `.xpi` for self-install. Not on the AMO store. Enough to survive Firefox restarts. **Start here.** |
| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. Do this when you want strangers to find it. |
Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, and the extension does not accept `onMessageExternal`.
Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, tokens are hashed at rest, and the extension does not accept `onMessageExternal`.
## 3. Sign (unlisted)
+55 -2
View File
@@ -21,6 +21,8 @@ const ALLOWED = {
let port = null;
let reconnectTimer = null;
let hostUp = false;
const adminWait = new Map();
function asArray(value) {
if (value === undefined || value === null) {
@@ -29,7 +31,7 @@ function asArray(value) {
return Array.isArray(value) ? value : [value];
}
async function dispatch(message) {
async function dispatchBookmark(message) {
const method = message && message.method;
const fn = ALLOWED[method];
if (!fn) {
@@ -38,12 +40,42 @@ async function dispatch(message) {
return fn(...asArray(message.args));
}
function adminCall(method, args) {
if (!port) {
return Promise.reject(new Error("native host is not connected"));
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
adminWait.delete(id);
reject(new Error("host did not answer"));
}, 8000);
adminWait.set(id, { resolve, reject, timer });
port.postMessage({ type: "admin", id, method, args: args || [] });
});
}
function attach(nextPort) {
port = nextPort;
hostUp = true;
port.onMessage.addListener(async (message) => {
if (message && message.type === "admin-result") {
const pending = adminWait.get(message.id);
if (!pending) {
return;
}
adminWait.delete(message.id);
clearTimeout(pending.timer);
if (message.ok) {
pending.resolve(message.result);
} else {
pending.reject(new Error(message.error || "admin failed"));
}
return;
}
const id = message && message.id;
try {
const result = await dispatch(message);
const result = await dispatchBookmark(message);
port.postMessage({ id, ok: true, result });
} catch (err) {
port.postMessage({
@@ -55,6 +87,12 @@ function attach(nextPort) {
});
port.onDisconnect.addListener(() => {
port = null;
hostUp = false;
for (const [id, pending] of adminWait.entries()) {
clearTimeout(pending.timer);
pending.reject(new Error("native host disconnected"));
adminWait.delete(id);
}
scheduleReconnect();
});
}
@@ -73,9 +111,24 @@ function connect() {
try {
attach(browser.runtime.connectNative(HOST_NAME));
} catch (err) {
hostUp = false;
console.error("native host connect failed", err);
scheduleReconnect();
}
}
browser.runtime.onMessage.addListener((message) => {
const kind = message && message.type;
if (kind === "status") {
return Promise.resolve({ hostUp, port: 17634 });
}
if (kind === "admin") {
return adminCall(message.method, message.args).then(
(result) => ({ ok: true, result }),
(err) => ({ ok: false, error: err.message })
);
}
return undefined;
});
connect();
+10 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Firefox Agent Bridge",
"version": "0.1.0",
"description": "Expose an allowlisted WebExtension API to local scripts and agents over native messaging.",
"version": "0.2.0",
"description": "Let local scripts and agents call an allowlisted WebExtension API after you issue them a secret.",
"browser_specific_settings": {
"gecko": {
"id": "firefox-agent-bridge@easygoingaming.com",
@@ -12,5 +12,13 @@
"permissions": ["bookmarks", "nativeMessaging"],
"background": {
"scripts": ["background.js"]
},
"action": {
"default_title": "Firefox Agent Bridge",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Firefox Agent Bridge</title>
<link rel="stylesheet" href="ui.css">
</head>
<body class="page">
<header>
<h1>Firefox Agent Bridge</h1>
<p class="muted">Issue a secret to each script or agent. The add-on keeps only a hash. Put the secret in an env var, vault, or <code>.env</code> — whatever you already use.</p>
<p id="status" class="muted"></p>
</header>
<section class="row">
<input id="name" type="text" maxlength="64" placeholder="Client name, e.g. cursor-agent" autocomplete="off">
<button id="create" type="button">Generate secret</button>
</section>
<p id="error" class="error" hidden></p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="4" class="muted">Loading…</td></tr>
</tbody>
</table>
<dialog id="secret-modal">
<h2>Copy this secret now</h2>
<p class="muted">It will not be shown again. Store it the same way you store any other API token.</p>
<label class="sr">Secret</label>
<textarea id="secret" readonly rows="3"></textarea>
<p class="hint">Example: <code>set FAB_TOKEN=&lt;secret&gt;</code> then <code>python tools/client.py meta.methods</code></p>
<div class="row">
<button id="copy" type="button">Copy</button>
<button id="close" type="button">I saved it</button>
</div>
</dialog>
<script src="options.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
"use strict";
const rows = document.getElementById("rows");
const nameInput = document.getElementById("name");
const createBtn = document.getElementById("create");
const errorEl = document.getElementById("error");
const statusEl = document.getElementById("status");
const modal = document.getElementById("secret-modal");
const secretEl = document.getElementById("secret");
const copyBtn = document.getElementById("copy");
const closeBtn = document.getElementById("close");
function showError(text) {
errorEl.hidden = !text;
errorEl.textContent = text || "";
}
function admin(method, args) {
return browser.runtime.sendMessage({ type: "admin", method, args }).then((resp) => {
if (!resp || !resp.ok) {
throw new Error((resp && resp.error) || "host error");
}
return resp.result;
});
}
function render(clients) {
if (!clients.length) {
rows.innerHTML = '<tr><td colspan="4" class="muted">No clients yet. Generate a secret to get started.</td></tr>';
return;
}
rows.innerHTML = "";
for (const client of clients) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(client.name)}</td>
<td>${escapeHtml(client.created || "")}</td>
<td>${escapeHtml(client.last_used || "never")}</td>
<td><button type="button" data-revoke="${escapeHtml(client.id)}">Revoke</button></td>`;
rows.appendChild(tr);
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function refresh() {
const info = await browser.runtime.sendMessage({ type: "status" });
statusEl.textContent = info && info.hostUp
? `Native host connected · http://127.0.0.1:${info.port}`
: "Native host is not connected. Install the host, then reload this add-on.";
if (!info || !info.hostUp) {
rows.innerHTML = '<tr><td colspan="4" class="muted">Host offline.</td></tr>';
return;
}
render(await admin("clients.list"));
}
createBtn.addEventListener("click", async () => {
showError("");
try {
const created = await admin("clients.create", [nameInput.value.trim()]);
nameInput.value = "";
secretEl.value = created.token;
modal.showModal();
await refresh();
} catch (err) {
showError(err.message);
}
});
rows.addEventListener("click", async (event) => {
const id = event.target && event.target.getAttribute("data-revoke");
if (!id) {
return;
}
showError("");
try {
await admin("clients.revoke", [id]);
await refresh();
} catch (err) {
showError(err.message);
}
});
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(secretEl.value);
copyBtn.textContent = "Copied";
} catch (err) {
showError(err.message);
}
});
closeBtn.addEventListener("click", () => {
secretEl.value = "";
copyBtn.textContent = "Copy";
modal.close();
});
refresh().catch((err) => showError(err.message));
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="ui.css">
</head>
<body class="popup">
<h1>Agent Bridge</h1>
<p id="status" class="muted">Checking host…</p>
<button id="manage" type="button">Manage clients</button>
<script src="popup.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
"use strict";
const statusEl = document.getElementById("status");
const manage = document.getElementById("manage");
browser.runtime.sendMessage({ type: "status" }).then((info) => {
if (info && info.hostUp) {
statusEl.textContent = `Host connected on 127.0.0.1:${info.port}`;
} else {
statusEl.textContent = "Native host is not connected. Is it installed?";
}
});
manage.addEventListener("click", () => {
browser.runtime.openOptionsPage();
});
+42
View File
@@ -0,0 +1,42 @@
:root {
color-scheme: light dark;
font: 14px/1.45 system-ui, sans-serif;
}
body {
margin: 0;
color: CanvasText;
background: Canvas;
}
.popup {
width: 280px;
padding: 12px;
}
.page {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
h1, h2 { margin: 0 0 8px; font-size: 1.15rem; }
.muted { color: GrayText; }
.row { display: flex; gap: 8px; margin: 16px 0; }
input[type="text"], textarea, button {
font: inherit;
}
input[type="text"], textarea {
flex: 1;
padding: 6px 8px;
}
textarea { width: 100%; box-sizing: border-box; }
button { padding: 6px 10px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, Canvas); }
.error { color: #b00020; }
.hint { font-size: 0.9rem; }
.sr { position: absolute; left: -9999px; }
dialog {
border: 1px solid color-mix(in srgb, CanvasText 20%, Canvas);
border-radius: 8px;
padding: 16px;
max-width: 36rem;
}
code { font-size: 0.9em; }
+76 -162
View File
@@ -1,42 +1,34 @@
"""Ed25519 client registration and request signing.
"""Hashed API-token clients for Firefox Agent Bridge.
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.
The host stores SHA-256(token) only. The raw secret is shown once in the
extension UI (or CLI) and then lives wherever the user keeps secrets.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import time
import uuid
import secrets
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}$")
TOKEN_PREFIX = "fab_"
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("ascii")).hexdigest()
def load_clients() -> list[dict[str, Any]]:
if not CLIENTS_PATH.exists():
return []
@@ -52,177 +44,99 @@ def save_clients(clients: list[dict[str, Any]]) -> None:
)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def public_client(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row.get("id"),
"name": row.get("name"),
"created": row.get("created"),
"last_used": row.get("last_used"),
}
def _unb64(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"))
def list_clients() -> list[dict[str, Any]]:
return [public_client(row) for row in load_clients()]
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]:
def create_client(name: str) -> dict[str, str]:
name = (name or "").strip()
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"
)
raise ValueError("name must be 1-64 characters: A-Za-z0-9._-")
clients = load_clients()
if any(c.get("name") == name for c in clients):
if any(row.get("name") == name for row 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")
token = TOKEN_PREFIX + secrets.token_hex(32)
client_id = secrets.token_hex(16)
clients.append(
{
"id": client_id,
"name": name,
"public_key_pem": pub_pem,
"token_hash": token_hash(token),
"created": _now(),
"last_used": None,
}
)
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)}
return {"id": client_id, "name": name, "token": token}
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]
kept = [row for row in clients if row.get("id") != name_or_id and row.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 touch_last_used(client_id: str) -> None:
clients = load_clients()
changed = False
stamp = _now()
for row in clients:
if row.get("id") == client_id:
row["last_used"] = stamp
changed = True
break
if changed:
save_clients(clients)
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 _hdr(headers: dict[str, str], name: str) -> str:
want = name.lower()
def verify_bearer(headers: dict[str, str]) -> dict[str, Any]:
auth = ""
for key, value in headers.items():
if key.lower() == want:
return (value or "").strip()
return ""
if key.lower() == "authorization":
auth = (value or "").strip()
break
if not auth.lower().startswith("bearer "):
raise PermissionError("Bearer token required")
token = auth[7:].strip()
if not token.startswith(TOKEN_PREFIX):
raise PermissionError("unrecognized token")
digest = token_hash(token)
matched = None
for row in load_clients():
stored = row.get("token_hash") or ""
if stored and hmac.compare_digest(stored, digest):
matched = row
break
if not matched:
raise PermissionError("unknown token")
touch_last_used(str(matched["id"]))
return public_client(matched)
def verify_request(
headers: dict[str, str],
http_method: str,
path: str,
raw_body: bytes,
replay: ReplayCache,
) -> dict[str, Any]:
auth = _hdr(headers, "Authorization")
match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth)
if not match:
raise PermissionError("signed FAB-ED25519 client required")
client_id = match.group(1)
timestamp = _hdr(headers, "X-FAB-Timestamp")
nonce = _hdr(headers, "X-FAB-Nonce")
signature = _hdr(headers, "X-FAB-Signature")
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
def handle_admin(method: str, args: list[Any] | None = None) -> Any:
args = args or []
if method == "clients.list":
return list_clients()
if method == "clients.create":
name = args[0] if args else ""
if isinstance(name, dict):
name = name.get("name") or ""
return create_client(str(name))
if method == "clients.revoke":
target = args[0] if args else ""
if isinstance(target, dict):
target = target.get("id") or target.get("name") or ""
return {"revoked": revoke_client(str(target))}
raise ValueError(f"unknown admin method: {method}")
+19 -4
View File
@@ -15,7 +15,7 @@ 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
from fab_auth import CLIENTS_PATH, handle_admin, verify_bearer # noqa: E402
if sys.platform == "win32":
import msvcrt
@@ -31,7 +31,6 @@ STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-ag
STDIN_LOCK = threading.Lock()
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
PENDING_LOCK = threading.Lock()
REPLAY = ReplayCache()
def log(msg: str) -> None:
@@ -112,7 +111,7 @@ class Handler(BaseHTTPRequestHandler):
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)
verify_bearer({k: v for k, v in self.headers.items()})
except PermissionError as exc:
self._send(401, {"ok": False, "error": str(exc)})
return False
@@ -209,6 +208,22 @@ def stdin_loop() -> None:
break
if message is None:
break
if message.get("type") == "admin":
try:
result = handle_admin(str(message.get("method") or ""), message.get("args") or [])
send_to_extension(
{"type": "admin-result", "id": message.get("id"), "ok": True, "result": result}
)
except Exception as exc:
send_to_extension(
{
"type": "admin-result",
"id": message.get("id"),
"ok": False,
"error": str(exc),
}
)
continue
req_id = str(message.get("id") or "")
with PENDING_LOCK:
pending = PENDING.pop(req_id, None)
@@ -223,7 +238,7 @@ def stdin_loop() -> None:
def main() -> int:
STATE_DIR.mkdir(parents=True, exist_ok=True)
if not CLIENTS_PATH.exists():
log(f"no registered clients at {CLIENTS_PATH}; run tools/register_client.py add")
log(f"no registered clients at {CLIENTS_PATH}; use the add-on Manage clients page")
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
log(f"listening on http://{HOST}:{PORT}")
-1
View File
@@ -1 +0,0 @@
cryptography>=42
+19 -22
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Call Firefox Agent Bridge with a registered Ed25519 client key."""
"""Call Firefox Agent Bridge with a Bearer token from FAB_TOKEN or a file."""
from __future__ import annotations
import argparse
@@ -11,21 +11,21 @@ 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")
def key_path() -> Path:
env = os.environ.get("FAB_KEY_FILE")
def token_value(explicit: str | None = None) -> str:
if explicit:
return explicit.strip()
env = os.environ.get("FAB_TOKEN")
if env:
return Path(env)
return env.strip()
path = os.environ.get("FAB_TOKEN_FILE")
if path:
return Path(path).expanduser().read_text(encoding="utf-8").strip()
raise SystemExit(
"no client key: set FAB_KEY_FILE or pass --key "
"(python tools/register_client.py add --name NAME --write-key PATH)"
"no token: set FAB_TOKEN, or FAB_TOKEN_FILE, or pass --token. "
"Generate one from the add-on toolbar → Manage clients."
)
@@ -33,20 +33,17 @@ def call(
method: str,
args: list[Any] | None = None,
base: str = DEFAULT_BASE,
key_file: Path | None = None,
token: str | 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("/") + path,
base.rstrip("/") + "/v1/call",
data=payload,
method="POST",
headers=headers,
headers={
"Authorization": f"Bearer {token_value(token)}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
@@ -69,12 +66,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)")
parser.add_argument("--token", help="override FAB_TOKEN")
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, key_file=ns.key), indent=2))
print(json.dumps(call(ns.method, args, base=ns.url, token=ns.token), indent=2))
return 0
+2 -3
View File
@@ -37,6 +37,5 @@ Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath
Write-Host "registered $regPath"
Write-Host ""
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."
Write-Host "Load the add-on, then use the toolbar icon → Manage clients to generate a secret."
Write-Host "Put that fab_… token in FAB_TOKEN for scripts. Do not store it under $StateDir."
+15 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Register, list, or revoke Ed25519 clients for Firefox Agent Bridge."""
"""CLI fallback for client secrets. Prefer the add-on Manage clients page."""
from __future__ import annotations
import argparse
@@ -10,42 +10,30 @@ 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
from fab_auth import CLIENTS_PATH, create_client, list_clients, revoke_client # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description="Manage Firefox Agent Bridge clients")
parser = argparse.ArgumentParser(description="Manage Firefox Agent Bridge clients (CLI)")
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")
add = sub.add_parser("add", help="generate a secret (printed once)")
add.add_argument("--name", required=True)
sub.add_parser("list")
drop = sub.add_parser("revoke")
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)
created = create_client(ns.name)
print(created["token"])
print(
f"id={created['id']} name={created['name']} — store that token in FAB_TOKEN; it will not be shown again.",
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))
print(json.dumps({"store": str(CLIENTS_PATH), "clients": list_clients()}, indent=2))
return 0
revoke_client(ns.name_or_id)
print(json.dumps({"revoked": ns.name_or_id}))
print(json.dumps({"revoked": revoke_client(ns.name_or_id)}))
return 0