Replace shared bearer token with registered Ed25519 client keys.

This commit is contained in:
alexveley
2026-09-22 06:54:43 -04:00
parent 535b3ce837
commit 43d94932fb
9 changed files with 412 additions and 100 deletions
+220
View File
@@ -0,0 +1,220 @@
"""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 verify_request(
headers: dict[str, str],
http_method: str,
path: str,
raw_body: bytes,
replay: ReplayCache,
) -> dict[str, Any]:
auth = headers.get("Authorization") or headers.get("authorization") or ""
match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth.strip())
if not match:
raise PermissionError("signed FAB-ED25519 client required")
client_id = match.group(1)
timestamp = (headers.get("X-FAB-Timestamp") or headers.get("x-fab-timestamp") or "").strip()
nonce = (headers.get("X-FAB-Nonce") or headers.get("x-fab-nonce") or "").strip()
signature = (headers.get("X-FAB-Signature") or headers.get("x-fab-signature") or "").strip()
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