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
|
||||
|
||||
1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1`
|
||||
2. A **registered client key** for this tool (see Auth). The host has only the public half.
|
||||
3. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install)
|
||||
4. Firefox **running** (the host process is started by the extension)
|
||||
2. Add-on loaded (temporary via `about:debugging`, or a signed install)
|
||||
3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret
|
||||
4. Firefox **running**
|
||||
|
||||
If `http://127.0.0.1:17634/health` fails, the host is not up — open Firefox and confirm the extension is loaded. If `/health` works but `/v1/ready` fails, the stdio pipe is down; reload the extension.
|
||||
If `http://127.0.0.1:17634/health` fails, the host is not up. If `/health` works but calls 401, you do not have a current `FAB_TOKEN`.
|
||||
|
||||
## Auth
|
||||
|
||||
The loopback port is **not** an open local API. Each caller is a named Ed25519 client.
|
||||
This is a normal API token, not an SSH key.
|
||||
|
||||
Register once per tool (human or agent operator does this; do not write the private key under `%LOCALAPPDATA%\firefox-agent-bridge`):
|
||||
1. In Firefox, open the Agent Bridge toolbar icon → **Manage clients**.
|
||||
2. Name the client (e.g. `cursor-agent`) and click **Generate secret**.
|
||||
3. Copy the `fab_…` value **once**. Store it however you already store secrets: env var, Vaultwarden, `.env`, CI secret, Cursor env.
|
||||
4. Give tooling only that string:
|
||||
|
||||
```text
|
||||
python tools/register_client.py add --name cursor-agent --write-key %USERPROFILE%\.fab\cursor-agent.json
|
||||
set FAB_TOKEN=fab_…
|
||||
python tools/client.py meta.methods
|
||||
```
|
||||
|
||||
Give tooling **only** that key file:
|
||||
`FAB_TOKEN_FILE` pointing at a file that contains only the token is also fine. `--token` on the CLI overrides both.
|
||||
|
||||
The add-on stores **SHA-256(token)** only. Revoke from the same page. A local process that never received the secret cannot call the API.
|
||||
|
||||
CLI fallback (same store, prints the token once):
|
||||
|
||||
```text
|
||||
set FAB_KEY_FILE=%USERPROFILE%\.fab\cursor-agent.json
|
||||
python tools/register_client.py add --name cursor-agent
|
||||
```
|
||||
|
||||
or `python tools/client.py --key PATH …`.
|
||||
|
||||
The host stores public keys in `%LOCALAPPDATA%\firefox-agent-bridge\clients.json`. Revoke with `python tools/register_client.py revoke NAME_OR_ID`. List with `… list`.
|
||||
|
||||
Every request except `/health` must be signed:
|
||||
|
||||
```
|
||||
Authorization: FAB-ED25519 id=<client_id>
|
||||
X-FAB-Timestamp: <unix seconds>
|
||||
X-FAB-Nonce: <unique hex>
|
||||
X-FAB-Signature: <base64 Ed25519 of canonical message>
|
||||
```
|
||||
|
||||
Canonical message (UTF-8, newline-separated):
|
||||
|
||||
```text
|
||||
v1
|
||||
<client_id>
|
||||
<timestamp>
|
||||
<nonce>
|
||||
<HTTP_METHOD>
|
||||
<path>
|
||||
<sha256 hex of raw body>
|
||||
```
|
||||
|
||||
Skew allowance is 90 seconds. Nonces cannot be reused. `tools/client.py` builds this for you.
|
||||
|
||||
Do not use a shared bearer token. Do not commit private key bundles. Do not paste PEM material into chat.
|
||||
|
||||
## Preferred caller
|
||||
|
||||
```text
|
||||
python tools/client.py --key KEYFILE METHOD [ARGS_JSON]
|
||||
python tools/client.py METHOD [ARGS_JSON]
|
||||
```
|
||||
|
||||
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json meta.methods
|
||||
python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||
```
|
||||
|
||||
HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"method":"bookmarks.search","args":[{"title":"10.132.x.x"}]}`.
|
||||
HTTP:
|
||||
|
||||
## Allowlisted methods (v0.1)
|
||||
```http
|
||||
POST /v1/call
|
||||
Authorization: Bearer fab_…
|
||||
Content-Type: application/json
|
||||
|
||||
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
|
||||
```
|
||||
|
||||
## Allowlisted methods (v0.2)
|
||||
|
||||
| Method | Args | Notes |
|
||||
|--------|------|--------|
|
||||
@@ -93,58 +77,38 @@ HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"me
|
||||
|
||||
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
|
||||
|
||||
REST aliases (same signature):
|
||||
|
||||
| HTTP | Maps to |
|
||||
|------|---------|
|
||||
| `GET /health` | host process only (no key) |
|
||||
| `GET /v1/ready` | `meta.ping` |
|
||||
| `GET /v1/methods` | `meta.methods` |
|
||||
| `GET /v1/bookmarks/tree` | `bookmarks.getTree` |
|
||||
| `GET /v1/bookmarks/{id}` | `bookmarks.get` |
|
||||
| `POST /v1/bookmarks/search` | body is the query object |
|
||||
| `POST /v1/bookmarks/create` | body is `CreateDetails` |
|
||||
| `POST /v1/bookmarks/update` | `{id, changes}` |
|
||||
| `POST /v1/bookmarks/move` | `{id, destination}` |
|
||||
| `POST /v1/bookmarks/remove` | `{id}` |
|
||||
| `POST /v1/bookmarks/remove-tree` | `{id}` |
|
||||
| `POST /v1/call` | `{method, args}` |
|
||||
REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
|
||||
|
||||
## Typical folder replace
|
||||
|
||||
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url` (that is the folder). Fail if zero or many.
|
||||
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url`. Fail if zero or many.
|
||||
2. `bookmarks.getChildren` on that id.
|
||||
3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder.
|
||||
4. `bookmarks.create` each new item with `parentId` set.
|
||||
|
||||
`examples/replace_named_folder.py` does exactly that (`FAB_KEY_FILE` must be set). It is an example, not a sync service.
|
||||
|
||||
You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified`). Operate on a named subfolder (toolbar / menu / a folder the user already created).
|
||||
`examples/replace_named_folder.py` does that (`FAB_TOKEN` must be set).
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Symptom | Cause | What to do |
|
||||
|---------|--------|------------|
|
||||
| Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on |
|
||||
| 401 `signed FAB-ED25519 client required` | Old bearer token or unsigned curl | Use `tools/client.py` and a registered key |
|
||||
| 401 `unknown client id` | Key revoked or host has no `clients.json` | `register_client.py list` / `add` |
|
||||
| 401 `bad signature` / `replayed nonce` / skew | Wrong key, reused request, or clock drift | New request; check `FAB_KEY_FILE` |
|
||||
| 502 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` |
|
||||
| 502 timeout | Extension died mid-call | Reload the add-on |
|
||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted |
|
||||
| Native host not found | Installer not run | `tools/install-native-host.ps1` |
|
||||
| Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host |
|
||||
| 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
|
||||
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
|
||||
| 502 `method not allowed` | Typo or API not in `ALLOWED` | `meta.methods` |
|
||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- The extension does **not** listen on `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
|
||||
- The native host manifest `allowed_extensions` is pinned to `firefox-agent-bridge@easygoingaming.com`. A different add-on cannot `connectNative` to this host.
|
||||
- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected. `Host` must be loopback.
|
||||
- Local processes **without** a registered private key cannot edit bookmarks through this port. The public store is useless for impersonation.
|
||||
- Another add-on that already has the `bookmarks` permission does not need this bridge.
|
||||
- No `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
|
||||
- Native host `allowed_extensions` is pinned to this add-on's gecko id.
|
||||
- HTTP is `127.0.0.1` only; browser `Origin` headers are rejected.
|
||||
- Secrets are ordinary Bearer tokens. Do not write them under `%LOCALAPPDATA%\firefox-agent-bridge`.
|
||||
- Another add-on with the `bookmarks` permission already has Places.
|
||||
- Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
|
||||
|
||||
## What this project is not
|
||||
|
||||
- Not a bidirectional sync implementation
|
||||
- Not a general Firefox remote-control surface (no tabs, history, cookies, native file access)
|
||||
- Not a general Firefox remote-control surface
|
||||
- Not a reason to keep editing `places.sqlite` on a live profile
|
||||
|
||||
Reference in New Issue
Block a user