Replace Ed25519 with hashed API tokens and an in-Firefox client manager.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=<secret></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>
|
||||||
@@ -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, "&")
|
||||||
|
.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 = '<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));
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
cryptography>=42
|
|
||||||
+19
-22
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user