From 9242c01015fb5a3652ab4dbac04261ae3b5cd114 Mon Sep 17 00:00:00 2001 From: alexveley Date: Tue, 22 Sep 2026 07:24:35 -0400 Subject: [PATCH] Replace Ed25519 with hashed API tokens and an in-Firefox client manager. --- AGENTS.md | 122 ++++++--------- README.md | 57 +++---- docs/signing.md | 2 +- extension/background.js | 57 ++++++- extension/manifest.json | 12 +- extension/options.html | 49 ++++++ extension/options.js | 106 +++++++++++++ extension/popup.html | 13 ++ extension/popup.js | 16 ++ extension/ui.css | 42 ++++++ fab_auth.py | 238 ++++++++++-------------------- host/firefox_agent_bridge_host.py | 23 ++- requirements.txt | 1 - tools/client.py | 41 +++-- tools/install-native-host.ps1 | 5 +- tools/register_client.py | 42 ++---- 16 files changed, 487 insertions(+), 339 deletions(-) create mode 100644 extension/options.html create mode 100644 extension/options.js create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 extension/ui.css delete mode 100644 requirements.txt diff --git a/AGENTS.md b/AGENTS.md index 97e0eb2..ff2794e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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= -X-FAB-Timestamp: -X-FAB-Nonce: -X-FAB-Signature: -``` - -Canonical message (UTF-8, newline-separated): - -```text -v1 - - - - - - -``` - -Skew allowance is 90 seconds. Nonces cannot be reused. `tools/client.py` builds this for you. - -Do not use a shared bearer token. Do not commit private key bundles. Do not paste PEM material into chat. - ## Preferred caller ```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`, ``, 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 diff --git a/README.md b/README.md index af6f9a2..b5b82ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/signing.md b/docs/signing.md index 932522a..38b3118 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -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) diff --git a/extension/background.js b/extension/background.js index 162bae9..803bc33 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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(); diff --git a/extension/manifest.json b/extension/manifest.json index 1bb61cf..1ee54b1 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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 } } diff --git a/extension/options.html b/extension/options.html new file mode 100644 index 0000000..24f7cef --- /dev/null +++ b/extension/options.html @@ -0,0 +1,49 @@ + + + + + Firefox Agent Bridge + + + +
+

Firefox Agent Bridge

+

Issue a secret to each script or agent. The add-on keeps only a hash. Put the secret in an env var, vault, or .env — whatever you already use.

+

+
+ +
+ + +
+ + + + + + + + + + + + + + +
NameCreatedLast used
Loading…
+ + +

Copy this secret now

+

It will not be shown again. Store it the same way you store any other API token.

+ + +

Example: set FAB_TOKEN=<secret> then python tools/client.py meta.methods

+
+ + +
+
+ + + + diff --git a/extension/options.js b/extension/options.js new file mode 100644 index 0000000..8e9121f --- /dev/null +++ b/extension/options.js @@ -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 = 'No clients yet. Generate a secret to get started.'; + return; + } + rows.innerHTML = ""; + for (const client of clients) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escapeHtml(client.name)} + ${escapeHtml(client.created || "")} + ${escapeHtml(client.last_used || "never")} + `; + rows.appendChild(tr); + } +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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 = 'Host offline.'; + 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)); diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..3b581e2 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,13 @@ + + + + + + + +

Agent Bridge

+

Checking host…

+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..10b0bcc --- /dev/null +++ b/extension/popup.js @@ -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(); +}); diff --git a/extension/ui.css b/extension/ui.css new file mode 100644 index 0000000..b0ae5ee --- /dev/null +++ b/extension/ui.css @@ -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; } diff --git a/fab_auth.py b/fab_auth.py index 8b630f4..6d9ced6 100644 --- a/fab_auth.py +++ b/fab_auth.py @@ -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}") diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py index 1c06aed..7cb3d3c 100644 --- a/host/firefox_agent_bridge_host.py +++ b/host/firefox_agent_bridge_host.py @@ -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}") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 65b9689..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cryptography>=42 diff --git a/tools/client.py b/tools/client.py index 9f16c8a..ea32df1 100644 --- a/tools/client.py +++ b/tools/client.py @@ -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 diff --git a/tools/install-native-host.ps1 b/tools/install-native-host.ps1 index d683c01..82cd91b 100644 --- a/tools/install-native-host.ps1 +++ b/tools/install-native-host.ps1 @@ -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." diff --git a/tools/register_client.py b/tools/register_client.py index 19d984d..84dbb8b 100644 --- a/tools/register_client.py +++ b/tools/register_client.py @@ -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