* feat(commands): add /use to force a skill for the next turn (#3517, #2977) Co-authored-by: Rod Boev <rod.boev@gmail.com> * fix(auth): cap pending passkey challenges by evicting oldest, not rejecting (#3624) Co-authored-by: Hinotobi <paperlantern.agent@gmail.com> * fix(providers): expose model-provider plugins in WebUI (#3613) Co-authored-by: Pamnard <pamnard@users.noreply.github.com> * docs(changelog): v0.51.270 — Release IL (stage-u1, 3-PR un-hold batch; #3448 + #3618 dropped) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: Hinotobi <paperlantern.agent@gmail.com> Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
15 files changed
+799
-33
No files matched your search
+47
-7
@@ -12,6 +12,7 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -29,7 +30,10 @@ except Exception: # pragma: no cover - exercised by source tests instead
|
||||
|
||||
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
|
||||
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
|
||||
_CHALLENGE_TTL = 300
|
||||
_CHALLENGE_TTL = 90
|
||||
_MAX_CHALLENGES = 128
|
||||
_MAX_CHALLENGES_PER_CONTEXT = 8
|
||||
_CHALLENGES_LOCK = threading.Lock()
|
||||
_RP_NAME = "Hermes WebUI"
|
||||
|
||||
|
||||
@@ -37,6 +41,10 @@ class PasskeyError(ValueError):
|
||||
"""Raised for user-correctable WebAuthn failures."""
|
||||
|
||||
|
||||
class PasskeyRateLimitError(PasskeyError):
|
||||
"""Raised when too many outstanding WebAuthn challenges are pending."""
|
||||
|
||||
|
||||
def _b64u(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
@@ -104,6 +112,8 @@ def passkeys_available() -> bool:
|
||||
|
||||
|
||||
def _load_challenges() -> dict[str, dict[str, Any]]:
|
||||
# May prune and rewrite the challenge file; callers that mutate the store
|
||||
# must hold _CHALLENGES_LOCK across load→mutate→write.
|
||||
raw = _json_load(_CHALLENGES_FILE, {})
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
@@ -117,16 +127,46 @@ def _load_challenges() -> dict[str, dict[str, Any]]:
|
||||
return clean
|
||||
|
||||
|
||||
def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
|
||||
if not keys:
|
||||
return None
|
||||
return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
|
||||
|
||||
|
||||
def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
|
||||
"""Keep the challenge store bounded while admitting the newest challenge."""
|
||||
while True:
|
||||
same_context = [
|
||||
k for k, v in data.items()
|
||||
if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
|
||||
]
|
||||
if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
|
||||
break
|
||||
oldest = _oldest_challenge_key(data, same_context)
|
||||
if oldest is None:
|
||||
break
|
||||
data.pop(oldest, None)
|
||||
|
||||
while len(data) >= _MAX_CHALLENGES:
|
||||
oldest = _oldest_challenge_key(data, list(data))
|
||||
if oldest is None:
|
||||
break
|
||||
data.pop(oldest, None)
|
||||
|
||||
|
||||
def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
|
||||
data = _load_challenges()
|
||||
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
|
||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
||||
with _CHALLENGES_LOCK:
|
||||
data = _load_challenges()
|
||||
_evict_oldest_challenges(data, kind, rp_id, origin)
|
||||
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
|
||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
||||
|
||||
|
||||
def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
|
||||
data = _load_challenges()
|
||||
entry = data.pop(challenge, None)
|
||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
||||
with _CHALLENGES_LOCK:
|
||||
data = _load_challenges()
|
||||
entry = data.pop(challenge, None)
|
||||
_atomic_write_json(_CHALLENGES_FILE, data)
|
||||
if not entry or entry.get("kind") != kind:
|
||||
raise PasskeyError("Passkey challenge expired. Try again.")
|
||||
return entry
|
||||
|
||||
Reference in new issue
Block a user