Replace Ed25519 with hashed API tokens and an in-Firefox client manager.
This commit is contained in:
+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
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user