"""Ed25519 client registration and request signing. 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. """ from __future__ import annotations import base64 import hashlib import json import os import re import time import uuid 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}$") def _now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def load_clients() -> list[dict[str, Any]]: if not CLIENTS_PATH.exists(): return [] data = json.loads(CLIENTS_PATH.read_text(encoding="utf-8")) return list(data.get("clients") or []) def save_clients(clients: list[dict[str, Any]]) -> None: STATE_DIR.mkdir(parents=True, exist_ok=True) CLIENTS_PATH.write_text( json.dumps({"clients": clients}, indent=2) + "\n", encoding="utf-8", ) def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") def _unb64(text: str) -> bytes: return base64.b64decode(text.encode("ascii")) 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]: 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" ) clients = load_clients() if any(c.get("name") == name for c 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") clients.append( { "id": client_id, "name": name, "public_key_pem": pub_pem, "created": _now(), } ) 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)} 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] 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 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() for key, value in headers.items(): if key.lower() == want: return (value or "").strip() return "" 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