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
+76 -162
View File
@@ -1,42 +1,34 @@
"""Ed25519 client registration and request signing.
"""Hashed API-token clients for Firefox Agent Bridge.
The host stores public keys only. Private keys are written where the user
says and passed into tooling — never next to the native host state.
The host stores SHA-256(token) only. The raw secret is shown once in the
extension UI (or CLI) and then lives wherever the user keeps secrets.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import time
import uuid
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
load_pem_public_key,
)
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
CLIENTS_PATH = STATE_DIR / "clients.json"
SKEW_SECONDS = 90
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
TOKEN_PREFIX = "fab_"
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("ascii")).hexdigest()
def load_clients() -> list[dict[str, Any]]:
if not CLIENTS_PATH.exists():
return []
@@ -52,177 +44,99 @@ def save_clients(clients: list[dict[str, Any]]) -> None:
)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def public_client(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row.get("id"),
"name": row.get("name"),
"created": row.get("created"),
"last_used": row.get("last_used"),
}
def _unb64(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"))
def list_clients() -> list[dict[str, Any]]:
return [public_client(row) for row in load_clients()]
def body_hash(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def canonical(
client_id: str,
timestamp: str,
nonce: str,
http_method: str,
path: str,
raw_body: bytes,
) -> bytes:
return "\n".join(
[
"v1",
client_id,
timestamp,
nonce,
http_method.upper(),
path,
body_hash(raw_body),
]
).encode("utf-8")
def register_client(name: str, key_path: Path) -> dict[str, str]:
def create_client(name: str) -> dict[str, str]:
name = (name or "").strip()
if not NAME_RE.match(name):
raise ValueError("name must be 1-64 chars of A-Za-z0-9._-")
key_path = key_path.expanduser().resolve()
if STATE_DIR in key_path.parents or key_path.parent == STATE_DIR:
raise ValueError(
f"refusing to write a private key under {STATE_DIR} — pick a path you will give to tooling"
)
raise ValueError("name must be 1-64 characters: A-Za-z0-9._-")
clients = load_clients()
if any(c.get("name") == name for c in clients):
if any(row.get("name") == name for row in clients):
raise ValueError(f"client already registered: {name}")
private = Ed25519PrivateKey.generate()
public = private.public_key()
client_id = uuid.uuid4().hex
pub_pem = public.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii")
priv_pem = private.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode("ascii")
token = TOKEN_PREFIX + secrets.token_hex(32)
client_id = secrets.token_hex(16)
clients.append(
{
"id": client_id,
"name": name,
"public_key_pem": pub_pem,
"token_hash": token_hash(token),
"created": _now(),
"last_used": None,
}
)
save_clients(clients)
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
raise ValueError(f"key file already exists: {key_path}")
bundle = {
"id": client_id,
"name": name,
"private_key_pem": priv_pem,
"public_key_pem": pub_pem,
}
key_path.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8")
try:
os.chmod(key_path, 0o600)
except OSError:
pass
return {"id": client_id, "name": name, "key_file": str(key_path)}
return {"id": client_id, "name": name, "token": token}
def revoke_client(name_or_id: str) -> str:
clients = load_clients()
kept = [c for c in clients if c.get("id") != name_or_id and c.get("name") != name_or_id]
kept = [row for row in clients if row.get("id") != name_or_id and row.get("name") != name_or_id]
if len(kept) == len(clients):
raise ValueError(f"no client {name_or_id!r}")
save_clients(kept)
return name_or_id
def load_key_bundle(path: Path) -> dict[str, str]:
data = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not data.get("id") or not data.get("private_key_pem"):
raise ValueError("key file must contain id and private_key_pem")
return data
def touch_last_used(client_id: str) -> None:
clients = load_clients()
changed = False
stamp = _now()
for row in clients:
if row.get("id") == client_id:
row["last_used"] = stamp
changed = True
break
if changed:
save_clients(clients)
def sign_headers(
bundle: dict[str, str],
http_method: str,
path: str,
raw_body: bytes,
) -> dict[str, str]:
private = load_pem_private_key(bundle["private_key_pem"].encode("ascii"), password=None)
if not isinstance(private, Ed25519PrivateKey):
raise ValueError("key file is not an Ed25519 private key")
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
message = canonical(bundle["id"], timestamp, nonce, http_method, path, raw_body)
signature = _b64(private.sign(message))
return {
"Authorization": f"FAB-ED25519 id={bundle['id']}",
"X-FAB-Timestamp": timestamp,
"X-FAB-Nonce": nonce,
"X-FAB-Signature": signature,
}
class ReplayCache:
def __init__(self, limit: int = 2048) -> None:
self.limit = limit
self._seen: dict[str, float] = {}
def accept(self, nonce: str, now: float) -> bool:
cutoff = now - SKEW_SECONDS
stale = [key for key, ts in self._seen.items() if ts < cutoff]
for key in stale:
del self._seen[key]
if nonce in self._seen:
return False
self._seen[nonce] = now
if len(self._seen) > self.limit:
oldest = sorted(self._seen, key=self._seen.get)[: len(self._seen) - self.limit]
for key in oldest:
del self._seen[key]
return True
def _hdr(headers: dict[str, str], name: str) -> str:
want = name.lower()
def verify_bearer(headers: dict[str, str]) -> dict[str, Any]:
auth = ""
for key, value in headers.items():
if key.lower() == want:
return (value or "").strip()
return ""
if key.lower() == "authorization":
auth = (value or "").strip()
break
if not auth.lower().startswith("bearer "):
raise PermissionError("Bearer token required")
token = auth[7:].strip()
if not token.startswith(TOKEN_PREFIX):
raise PermissionError("unrecognized token")
digest = token_hash(token)
matched = None
for row in load_clients():
stored = row.get("token_hash") or ""
if stored and hmac.compare_digest(stored, digest):
matched = row
break
if not matched:
raise PermissionError("unknown token")
touch_last_used(str(matched["id"]))
return public_client(matched)
def verify_request(
headers: dict[str, str],
http_method: str,
path: str,
raw_body: bytes,
replay: ReplayCache,
) -> dict[str, Any]:
auth = _hdr(headers, "Authorization")
match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth)
if not match:
raise PermissionError("signed FAB-ED25519 client required")
client_id = match.group(1)
timestamp = _hdr(headers, "X-FAB-Timestamp")
nonce = _hdr(headers, "X-FAB-Nonce")
signature = _hdr(headers, "X-FAB-Signature")
if not timestamp.isdigit() or not nonce or not signature:
raise PermissionError("missing signature headers")
now = time.time()
if abs(now - int(timestamp)) > SKEW_SECONDS:
raise PermissionError("timestamp outside allowed skew")
if not replay.accept(nonce, now):
raise PermissionError("replayed nonce")
client = next((c for c in load_clients() if c.get("id") == client_id), None)
if not client:
raise PermissionError("unknown client id")
public = load_pem_public_key(client["public_key_pem"].encode("ascii"))
if not isinstance(public, Ed25519PublicKey):
raise PermissionError("stored key is not Ed25519")
message = canonical(client_id, timestamp, nonce, http_method, path, raw_body)
try:
public.verify(_unb64(signature), message)
except InvalidSignature as exc:
raise PermissionError("bad signature") from exc
return client
def handle_admin(method: str, args: list[Any] | None = None) -> Any:
args = args or []
if method == "clients.list":
return list_clients()
if method == "clients.create":
name = args[0] if args else ""
if isinstance(name, dict):
name = name.get("name") or ""
return create_client(str(name))
if method == "clients.revoke":
target = args[0] if args else ""
if isinstance(target, dict):
target = target.get("id") or target.get("name") or ""
return {"revoked": revoke_client(str(target))}
raise ValueError(f"unknown admin method: {method}")