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 ## 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. A **registered client key** for this tool (see Auth). The host has only the public half. 2. Add-on loaded (temporary via `about:debugging`, or a signed install)
3. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install) 3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret
4. Firefox **running** (the host process is started by the extension) 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 ## 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 ```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 ```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 ## Preferred caller
```text ```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. `ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
Examples:
```text ```text
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\"}]"
``` ```
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 | | 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. Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
REST aliases (same signature): REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
| 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}` |
## Typical folder replace ## 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. 2. `bookmarks.getChildren` on that id.
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 (`FAB_KEY_FILE` must be set). It is an example, not a sync service. `examples/replace_named_folder.py` does that (`FAB_TOKEN` must be set).
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).
## Failure modes ## Failure modes
| 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 add-on/host missing | Open Firefox; load add-on; install native host |
| 401 `signed FAB-ED25519 client required` | Old bearer token or unsigned curl | Use `tools/client.py` and a registered key | | 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
| 401 `unknown client id` | Key revoked or host has no `clients.json` | `register_client.py list` / `add` | | 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
| 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` | `meta.methods` |
| 502 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` | | Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
| 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` |
## Security boundaries ## Security boundaries
- The extension does **not** listen on `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher. - No `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. - Native host `allowed_extensions` is pinned to this add-on's gecko id.
- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected. `Host` must be loopback. - HTTP is `127.0.0.1` only; browser `Origin` headers are rejected.
- Local processes **without** a registered private key cannot edit bookmarks through this port. The public store is useless for impersonation. - Secrets are ordinary Bearer tokens. Do not write them under `%LOCALAPPDATA%\firefox-agent-bridge`.
- Another add-on that already has the `bookmarks` permission does not need this 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. - Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
## What this project is not ## What this project is not
- Not a bidirectional sync implementation - 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 - Not a reason to keep editing `places.sqlite` on a live profile
+21 -36
View File
@@ -1,8 +1,8 @@
# Firefox Agent Bridge # 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). 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. 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 ## Setup
| 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)
1. Register the native host (once): 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 powershell -NoProfile -File tools\install-native-host.ps1
``` ```
That registers 2. Load the add-on (`about:debugging` temporary, or a signed `.xpi` — [docs/signing.md](docs/signing.md)).
`HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`.
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 4. Leave Firefox open. `GET http://127.0.0.1:17634/health` should return JSON.
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.
## Call it ## Call it
```powershell ```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 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 ## 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.
- Calls (except `/health`) must be signed by a **registered Ed25519 client**. The host keeps public keys only. - Each caller is an issued token. The add-on stores a hash, not the secret.
- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, tabs, or `onMessageExternal` in v0.1. - Only `bookmarks.*` (plus `meta.*`) in `extension/background.js`. No history, cookies, tabs, or `onMessageExternal`.
- Another add-on with `bookmarks` already has Places; this bridge does not give it a new path in.
## 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 ## 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.** | | **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. | | **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) ## 3. Sign (unlisted)
+55 -2
View File
@@ -21,6 +21,8 @@ const ALLOWED = {
let port = null; let port = null;
let reconnectTimer = null; let reconnectTimer = null;
let hostUp = false;
const adminWait = new Map();
function asArray(value) { function asArray(value) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -29,7 +31,7 @@ function asArray(value) {
return Array.isArray(value) ? value : [value]; return Array.isArray(value) ? value : [value];
} }
async function dispatch(message) { async function dispatchBookmark(message) {
const method = message && message.method; const method = message && message.method;
const fn = ALLOWED[method]; const fn = ALLOWED[method];
if (!fn) { if (!fn) {
@@ -38,12 +40,42 @@ async function dispatch(message) {
return fn(...asArray(message.args)); 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) { function attach(nextPort) {
port = nextPort; port = nextPort;
hostUp = true;
port.onMessage.addListener(async (message) => { 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; const id = message && message.id;
try { try {
const result = await dispatch(message); const result = await dispatchBookmark(message);
port.postMessage({ id, ok: true, result }); port.postMessage({ id, ok: true, result });
} catch (err) { } catch (err) {
port.postMessage({ port.postMessage({
@@ -55,6 +87,12 @@ function attach(nextPort) {
}); });
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
port = null; 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(); scheduleReconnect();
}); });
} }
@@ -73,9 +111,24 @@ function connect() {
try { try {
attach(browser.runtime.connectNative(HOST_NAME)); attach(browser.runtime.connectNative(HOST_NAME));
} catch (err) { } catch (err) {
hostUp = false;
console.error("native host connect failed", err); console.error("native host connect failed", err);
scheduleReconnect(); 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(); connect();
+10 -2
View File
@@ -1,8 +1,8 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Firefox Agent Bridge", "name": "Firefox Agent Bridge",
"version": "0.1.0", "version": "0.2.0",
"description": "Expose an allowlisted WebExtension API to local scripts and agents over native messaging.", "description": "Let local scripts and agents call an allowlisted WebExtension API after you issue them a secret.",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "firefox-agent-bridge@easygoingaming.com", "id": "firefox-agent-bridge@easygoingaming.com",
@@ -12,5 +12,13 @@
"permissions": ["bookmarks", "nativeMessaging"], "permissions": ["bookmarks", "nativeMessaging"],
"background": { "background": {
"scripts": ["background.js"] "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 The host stores SHA-256(token) only. The raw secret is shown once in the
says and passed into tooling — never next to the native host state. extension UI (or CLI) and then lives wherever the user keeps secrets.
""" """
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import hmac
import json import json
import os import os
import re import re
import time import secrets
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any 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" STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
CLIENTS_PATH = STATE_DIR / "clients.json" CLIENTS_PATH = STATE_DIR / "clients.json"
SKEW_SECONDS = 90
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
TOKEN_PREFIX = "fab_"
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat() 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]]: def load_clients() -> list[dict[str, Any]]:
if not CLIENTS_PATH.exists(): if not CLIENTS_PATH.exists():
return [] return []
@@ -52,177 +44,99 @@ def save_clients(clients: list[dict[str, Any]]) -> None:
) )
def _b64(data: bytes) -> str: def public_client(row: dict[str, Any]) -> dict[str, Any]:
return base64.b64encode(data).decode("ascii") return {
"id": row.get("id"),
"name": row.get("name"),
"created": row.get("created"),
"last_used": row.get("last_used"),
}
def _unb64(text: str) -> bytes: def list_clients() -> list[dict[str, Any]]:
return base64.b64decode(text.encode("ascii")) return [public_client(row) for row in load_clients()]
def body_hash(raw: bytes) -> str: def create_client(name: str) -> dict[str, str]:
return hashlib.sha256(raw).hexdigest() name = (name or "").strip()
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): if not NAME_RE.match(name):
raise ValueError("name must be 1-64 chars of A-Za-z0-9._-") raise ValueError("name must be 1-64 characters: 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() 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}") raise ValueError(f"client already registered: {name}")
private = Ed25519PrivateKey.generate() token = TOKEN_PREFIX + secrets.token_hex(32)
public = private.public_key() client_id = secrets.token_hex(16)
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( clients.append(
{ {
"id": client_id, "id": client_id,
"name": name, "name": name,
"public_key_pem": pub_pem, "token_hash": token_hash(token),
"created": _now(), "created": _now(),
"last_used": None,
} }
) )
save_clients(clients) save_clients(clients)
key_path.parent.mkdir(parents=True, exist_ok=True) return {"id": client_id, "name": name, "token": token}
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: def revoke_client(name_or_id: str) -> str:
clients = load_clients() 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): if len(kept) == len(clients):
raise ValueError(f"no client {name_or_id!r}") raise ValueError(f"no client {name_or_id!r}")
save_clients(kept) save_clients(kept)
return name_or_id return name_or_id
def load_key_bundle(path: Path) -> dict[str, str]: def touch_last_used(client_id: str) -> None:
data = json.loads(path.expanduser().read_text(encoding="utf-8")) clients = load_clients()
if not data.get("id") or not data.get("private_key_pem"): changed = False
raise ValueError("key file must contain id and private_key_pem") stamp = _now()
return data 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( def verify_bearer(headers: dict[str, str]) -> dict[str, Any]:
bundle: dict[str, str], auth = ""
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()
for key, value in headers.items(): for key, value in headers.items():
if key.lower() == want: if key.lower() == "authorization":
return (value or "").strip() auth = (value or "").strip()
return "" 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( def handle_admin(method: str, args: list[Any] | None = None) -> Any:
headers: dict[str, str], args = args or []
http_method: str, if method == "clients.list":
path: str, return list_clients()
raw_body: bytes, if method == "clients.create":
replay: ReplayCache, name = args[0] if args else ""
) -> dict[str, Any]: if isinstance(name, dict):
auth = _hdr(headers, "Authorization") name = name.get("name") or ""
match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth) return create_client(str(name))
if not match: if method == "clients.revoke":
raise PermissionError("signed FAB-ED25519 client required") target = args[0] if args else ""
client_id = match.group(1) if isinstance(target, dict):
timestamp = _hdr(headers, "X-FAB-Timestamp") target = target.get("id") or target.get("name") or ""
nonce = _hdr(headers, "X-FAB-Nonce") return {"revoked": revoke_client(str(target))}
signature = _hdr(headers, "X-FAB-Signature") raise ValueError(f"unknown admin method: {method}")
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
+19 -4
View File
@@ -15,7 +15,7 @@ from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) 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": if sys.platform == "win32":
import msvcrt import msvcrt
@@ -31,7 +31,6 @@ STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-ag
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:
@@ -112,7 +111,7 @@ class Handler(BaseHTTPRequestHandler):
if not self._gate_ok(): if not self._gate_ok():
return False return False
try: 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: except PermissionError as exc:
self._send(401, {"ok": False, "error": str(exc)}) self._send(401, {"ok": False, "error": str(exc)})
return False return False
@@ -209,6 +208,22 @@ def stdin_loop() -> None:
break break
if message is None: if message is None:
break 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 "") req_id = str(message.get("id") or "")
with PENDING_LOCK: with PENDING_LOCK:
pending = PENDING.pop(req_id, None) pending = PENDING.pop(req_id, None)
@@ -223,7 +238,7 @@ 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)
if not CLIENTS_PATH.exists(): 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() threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
log(f"listening on http://{HOST}:{PORT}") log(f"listening on http://{HOST}:{PORT}")
-1
View File
@@ -1 +0,0 @@
cryptography>=42
+19 -22
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import argparse
@@ -11,21 +11,21 @@ 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")
def key_path() -> Path: def token_value(explicit: str | None = None) -> str:
env = os.environ.get("FAB_KEY_FILE") if explicit:
return explicit.strip()
env = os.environ.get("FAB_TOKEN")
if env: 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( raise SystemExit(
"no client key: set FAB_KEY_FILE or pass --key " "no token: set FAB_TOKEN, or FAB_TOKEN_FILE, or pass --token. "
"(python tools/register_client.py add --name NAME --write-key PATH)" "Generate one from the add-on toolbar → Manage clients."
) )
@@ -33,20 +33,17 @@ def call(
method: str, method: str,
args: list[Any] | None = None, args: list[Any] | None = None,
base: str = DEFAULT_BASE, base: str = DEFAULT_BASE,
key_file: Path | None = None, token: str | None = None,
) -> Any: ) -> 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("/") + path, base.rstrip("/") + "/v1/call",
data=payload, data=payload,
method="POST", method="POST",
headers=headers, headers={
"Authorization": f"Bearer {token_value(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:
@@ -69,12 +66,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)") parser.add_argument("--token", help="override FAB_TOKEN")
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, key_file=ns.key), indent=2)) print(json.dumps(call(ns.method, args, base=ns.url, token=ns.token), indent=2))
return 0 return 0
+2 -3
View File
@@ -37,6 +37,5 @@ Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath
Write-Host "registered $regPath" Write-Host "registered $regPath"
Write-Host "" Write-Host ""
Write-Host "Register a client key for tooling (private key stays out of $StateDir):" Write-Host "Load the add-on, then use the toolbar icon → Manage clients to generate a secret."
Write-Host " python tools\register_client.py add --name cursor-agent --write-key `$HOME\.fab\cursor-agent.json" Write-Host "Put that fab_… token in FAB_TOKEN for scripts. Do not store it under $StateDir."
Write-Host "Then load the extension and set FAB_KEY_FILE to that path."
+15 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import argparse
@@ -10,42 +10,30 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) 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: 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) sub = parser.add_subparsers(dest="cmd", required=True)
add = sub.add_parser("add", help="generate a secret (printed once)")
add = sub.add_parser("add", help="generate a keypair and register the public half") add.add_argument("--name", required=True)
add.add_argument("--name", required=True, help="label, e.g. cursor-agent") sub.add_parser("list")
add.add_argument( drop = sub.add_parser("revoke")
"--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") drop.add_argument("name_or_id")
ns = parser.parse_args() ns = parser.parse_args()
if ns.cmd == "add": if ns.cmd == "add":
info = register_client(ns.name, ns.write_key) created = create_client(ns.name)
print(json.dumps(info, indent=2)) print(created["token"])
print(f"give {info['key_file']} to tooling via FAB_KEY_FILE or --key", file=sys.stderr) 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 return 0
if ns.cmd == "list": if ns.cmd == "list":
rows = [ print(json.dumps({"store": str(CLIENTS_PATH), "clients": list_clients()}, indent=2))
{"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 return 0
revoke_client(ns.name_or_id) print(json.dumps({"revoked": revoke_client(ns.name_or_id)}))
print(json.dumps({"revoked": ns.name_or_id}))
return 0 return 0