@bitseek/hermes-webui 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,365 @@
1
+ """Passkey/WebAuthn helpers for Hermes WebUI.
2
+
3
+ Default-off: passkeys are only advertised after an authenticated user registers
4
+ one from Settings. Password auth remains the bootstrap/recovery mechanism.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ import secrets
14
+ import tempfile
15
+ import time
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from api.config import STATE_DIR
21
+
22
+ try: # optional at import-time; endpoints return a clear error if unavailable
23
+ from cryptography.exceptions import InvalidSignature
24
+ from cryptography.hazmat.primitives import hashes, serialization
25
+ from cryptography.hazmat.primitives.asymmetric import ec
26
+ except Exception: # pragma: no cover - exercised by source tests instead
27
+ InvalidSignature = Exception # type: ignore[assignment]
28
+ hashes = serialization = ec = None # type: ignore[assignment]
29
+
30
+ _CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
31
+ _CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
32
+ _CHALLENGE_TTL = 300
33
+ _RP_NAME = "Hermes WebUI"
34
+
35
+
36
+ class PasskeyError(ValueError):
37
+ """Raised for user-correctable WebAuthn failures."""
38
+
39
+
40
+ def _b64u(data: bytes) -> str:
41
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
42
+
43
+
44
+ def _b64u_decode(value: str | bytes) -> bytes:
45
+ if isinstance(value, bytes):
46
+ value = value.decode("ascii")
47
+ value = str(value).strip()
48
+ value += "=" * (-len(value) % 4)
49
+ return base64.urlsafe_b64decode(value.encode("ascii"))
50
+
51
+
52
+ def _json_load(path: Path, default: Any) -> Any:
53
+ try:
54
+ if path.exists():
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+ except Exception:
57
+ return default
58
+ return default
59
+
60
+
61
+ def _atomic_write_json(path: Path, payload: Any) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
64
+ try:
65
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
66
+ json.dump(payload, f, indent=2, sort_keys=True)
67
+ os.chmod(tmp, 0o600)
68
+ os.replace(tmp, path)
69
+ except Exception:
70
+ try:
71
+ os.unlink(tmp)
72
+ except OSError:
73
+ pass
74
+ raise
75
+
76
+
77
+ def _load_credentials() -> list[dict[str, Any]]:
78
+ data = _json_load(_CREDENTIALS_FILE, [])
79
+ if not isinstance(data, list):
80
+ return []
81
+ return [c for c in data if isinstance(c, dict) and isinstance(c.get("id"), str)]
82
+
83
+
84
+ def _save_credentials(creds: list[dict[str, Any]]) -> None:
85
+ _atomic_write_json(_CREDENTIALS_FILE, creds)
86
+
87
+
88
+ def registered_credentials() -> list[dict[str, Any]]:
89
+ """Return public credential metadata only; never expose public keys."""
90
+ out = []
91
+ for c in _load_credentials():
92
+ out.append({
93
+ "id": c.get("id"),
94
+ "label": c.get("label") or "Passkey",
95
+ "created_at": c.get("created_at"),
96
+ "last_used_at": c.get("last_used_at"),
97
+ "sign_count": c.get("sign_count", 0),
98
+ })
99
+ return out
100
+
101
+
102
+ def passkeys_available() -> bool:
103
+ return bool(_load_credentials())
104
+
105
+
106
+ def _load_challenges() -> dict[str, dict[str, Any]]:
107
+ raw = _json_load(_CHALLENGES_FILE, {})
108
+ if not isinstance(raw, dict):
109
+ return {}
110
+ now = time.time()
111
+ clean = {
112
+ k: v for k, v in raw.items()
113
+ if isinstance(k, str) and isinstance(v, dict) and now - float(v.get("ts", 0)) < _CHALLENGE_TTL
114
+ }
115
+ if clean != raw:
116
+ _atomic_write_json(_CHALLENGES_FILE, clean)
117
+ return clean
118
+
119
+
120
+ def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
121
+ data = _load_challenges()
122
+ data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
123
+ _atomic_write_json(_CHALLENGES_FILE, data)
124
+
125
+
126
+ def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
127
+ data = _load_challenges()
128
+ entry = data.pop(challenge, None)
129
+ _atomic_write_json(_CHALLENGES_FILE, data)
130
+ if not entry or entry.get("kind") != kind:
131
+ raise PasskeyError("Passkey challenge expired. Try again.")
132
+ return entry
133
+
134
+
135
+ def _host_without_port(host: str) -> str:
136
+ host = (host or "localhost").strip().split(",", 1)[0]
137
+ if host.startswith("[") and "]" in host:
138
+ return host[1:host.index("]")]
139
+ return host.rsplit(":", 1)[0] if ":" in host else host
140
+
141
+
142
+ def rp_context(handler) -> tuple[str, str]:
143
+ host = _host_without_port(handler.headers.get("Host", "localhost"))
144
+ proto = handler.headers.get("X-Forwarded-Proto", "").split(",", 1)[0].strip().lower()
145
+ if proto not in {"http", "https"}:
146
+ try:
147
+ from api.auth import _is_secure_context
148
+ proto = "https" if _is_secure_context(handler) else "http"
149
+ except AttributeError:
150
+ proto = "http"
151
+ return host, f"{proto}://{handler.headers.get('Host', host)}"
152
+
153
+
154
+ def registration_options(handler) -> dict[str, Any]:
155
+ rp_id, _origin = rp_context(handler)
156
+ challenge = _b64u(secrets.token_bytes(32))
157
+ _store_challenge(challenge, "register", rp_id, _origin)
158
+ return {
159
+ "challenge": challenge,
160
+ "rp": {"name": _RP_NAME, "id": rp_id},
161
+ "user": {"id": _b64u(hashlib.sha256(rp_id.encode()).digest()[:16]), "name": "Hermes WebUI", "displayName": "Hermes WebUI"},
162
+ "pubKeyCredParams": [{"type": "public-key", "alg": -7}],
163
+ "authenticatorSelection": {"residentKey": "preferred", "userVerification": "preferred"},
164
+ "timeout": 60000,
165
+ "attestation": "none",
166
+ "excludeCredentials": [{"type": "public-key", "id": c["id"]} for c in registered_credentials()],
167
+ }
168
+
169
+
170
+ def authentication_options(handler) -> dict[str, Any]:
171
+ creds = registered_credentials()
172
+ if not creds:
173
+ raise PasskeyError("No passkeys are registered.")
174
+ rp_id, origin = rp_context(handler)
175
+ challenge = _b64u(secrets.token_bytes(32))
176
+ _store_challenge(challenge, "login", rp_id, origin)
177
+ return {
178
+ "challenge": challenge,
179
+ "rpId": rp_id,
180
+ "allowCredentials": [{"type": "public-key", "id": c["id"]} for c in creds],
181
+ "timeout": 60000,
182
+ "userVerification": "preferred",
183
+ }
184
+
185
+
186
+ @dataclass
187
+ class _Cbor:
188
+ data: bytes
189
+ pos: int = 0
190
+
191
+ def read(self, n: int) -> bytes:
192
+ if self.pos + n > len(self.data):
193
+ raise PasskeyError("Malformed CBOR data")
194
+ out = self.data[self.pos:self.pos + n]
195
+ self.pos += n
196
+ return out
197
+
198
+ def item(self) -> Any:
199
+ initial = self.read(1)[0]
200
+ major, addl = initial >> 5, initial & 0x1F
201
+ val = self._val(addl)
202
+ if major == 0:
203
+ return val
204
+ if major == 1:
205
+ return -1 - val
206
+ if major == 2:
207
+ return self.read(val)
208
+ if major == 3:
209
+ return self.read(val).decode("utf-8")
210
+ if major == 4:
211
+ return [self.item() for _ in range(val)]
212
+ if major == 5:
213
+ return {self.item(): self.item() for _ in range(val)}
214
+ if major == 7:
215
+ if val == 20:
216
+ return False
217
+ if val == 21:
218
+ return True
219
+ if val == 22:
220
+ return None
221
+ raise PasskeyError("Unsupported CBOR data")
222
+
223
+ def _val(self, addl: int) -> int:
224
+ if addl < 24:
225
+ return addl
226
+ if addl == 24:
227
+ return self.read(1)[0]
228
+ if addl == 25:
229
+ return int.from_bytes(self.read(2), "big")
230
+ if addl == 26:
231
+ return int.from_bytes(self.read(4), "big")
232
+ if addl == 27:
233
+ return int.from_bytes(self.read(8), "big")
234
+ raise PasskeyError("Indefinite CBOR values are not supported")
235
+
236
+
237
+ def _cbor_loads(data: bytes) -> Any:
238
+ parser = _Cbor(data)
239
+ value = parser.item()
240
+ if parser.pos != len(data):
241
+ raise PasskeyError("Trailing CBOR data")
242
+ return value
243
+
244
+
245
+ def _client_data(encoded: str, expected_type: str, challenge_kind: str) -> tuple[dict[str, Any], dict[str, Any], bytes]:
246
+ raw = _b64u_decode(encoded)
247
+ try:
248
+ data = json.loads(raw.decode("utf-8"))
249
+ except Exception as exc:
250
+ raise PasskeyError("Malformed client data") from exc
251
+ if data.get("type") != expected_type:
252
+ raise PasskeyError("Unexpected passkey response type")
253
+ challenge = data.get("challenge")
254
+ if not isinstance(challenge, str):
255
+ raise PasskeyError("Missing passkey challenge")
256
+ entry = _consume_challenge(challenge, challenge_kind)
257
+ if data.get("origin") != entry.get("origin"):
258
+ raise PasskeyError("Passkey origin mismatch")
259
+ return data, entry, raw
260
+
261
+
262
+ def _parse_auth_data(auth_data: bytes, rp_id: str) -> dict[str, Any]:
263
+ if len(auth_data) < 37:
264
+ raise PasskeyError("Malformed authenticator data")
265
+ rp_hash = auth_data[:32]
266
+ expected = hashlib.sha256(rp_id.encode("idna")).digest()
267
+ if not hmac.compare_digest(rp_hash, expected):
268
+ raise PasskeyError("Passkey RP ID mismatch")
269
+ flags = auth_data[32]
270
+ if not (flags & 0x01):
271
+ raise PasskeyError("Passkey user presence was not verified")
272
+ sign_count = int.from_bytes(auth_data[33:37], "big")
273
+ return {"flags": flags, "sign_count": sign_count, "rest": auth_data[37:]}
274
+
275
+
276
+ def _public_key_from_cose(cose: dict[Any, Any]):
277
+ if ec is None or serialization is None:
278
+ raise PasskeyError("Passkey support requires the cryptography package")
279
+ alg = cose.get(3)
280
+ kty = cose.get(1)
281
+ crv = cose.get(-1)
282
+ x = cose.get(-2)
283
+ y = cose.get(-3)
284
+ if alg != -7 or kty != 2 or crv != 1 or not isinstance(x, bytes) or not isinstance(y, bytes):
285
+ raise PasskeyError("Only ES256 passkeys are supported")
286
+ numbers = ec.EllipticCurvePublicNumbers(int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1())
287
+ return numbers.public_key()
288
+
289
+
290
+ def finish_registration(payload: dict[str, Any], handler) -> dict[str, Any]:
291
+ response = payload.get("response") or {}
292
+ _client, entry, _client_raw = _client_data(response.get("clientDataJSON", ""), "webauthn.create", "register")
293
+ att_obj = _cbor_loads(_b64u_decode(response.get("attestationObject", "")))
294
+ if not isinstance(att_obj, dict) or not isinstance(att_obj.get("authData"), bytes):
295
+ raise PasskeyError("Malformed attestation object")
296
+ parsed = _parse_auth_data(att_obj["authData"], entry["rp_id"])
297
+ if not (parsed["flags"] & 0x40):
298
+ raise PasskeyError("Passkey credential data missing")
299
+ rest = parsed["rest"]
300
+ if len(rest) < 18:
301
+ raise PasskeyError("Malformed credential data")
302
+ cred_len = int.from_bytes(rest[16:18], "big")
303
+ credential_id = rest[18:18 + cred_len]
304
+ cose_bytes = rest[18 + cred_len:]
305
+ cose_key = _cbor_loads(cose_bytes)
306
+ public_key = _public_key_from_cose(cose_key)
307
+ pem = public_key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode("ascii")
308
+ cred_id = _b64u(credential_id)
309
+ label = str(payload.get("label") or "Passkey").strip()[:80] or "Passkey"
310
+ creds = [c for c in _load_credentials() if c.get("id") != cred_id]
311
+ creds.append({
312
+ "id": cred_id,
313
+ "label": label,
314
+ "public_key_pem": pem,
315
+ "sign_count": parsed["sign_count"],
316
+ "created_at": time.time(),
317
+ "last_used_at": None,
318
+ })
319
+ _save_credentials(creds)
320
+ return {"ok": True, "credential": {"id": cred_id, "label": label}}
321
+
322
+
323
+ def finish_login(payload: dict[str, Any], handler) -> dict[str, Any]:
324
+ if serialization is None or hashes is None:
325
+ raise PasskeyError("Passkey support requires the cryptography package")
326
+ response = payload.get("response") or {}
327
+ cred_id = payload.get("id") or payload.get("rawId")
328
+ if not isinstance(cred_id, str):
329
+ raise PasskeyError("Missing passkey credential id")
330
+ creds = _load_credentials()
331
+ idx = next((i for i, c in enumerate(creds) if c.get("id") == cred_id), -1)
332
+ if idx < 0:
333
+ raise PasskeyError("Unknown passkey")
334
+ _client, entry, client_raw = _client_data(response.get("clientDataJSON", ""), "webauthn.get", "login")
335
+ auth_data = _b64u_decode(response.get("authenticatorData", ""))
336
+ parsed = _parse_auth_data(auth_data, entry["rp_id"])
337
+ signature = _b64u_decode(response.get("signature", ""))
338
+ public_key = serialization.load_pem_public_key(str(creds[idx].get("public_key_pem", "")).encode("ascii"))
339
+ signed = auth_data + hashlib.sha256(client_raw).digest()
340
+ try:
341
+ public_key.verify(signature, signed, ec.ECDSA(hashes.SHA256()))
342
+ except InvalidSignature as exc:
343
+ raise PasskeyError("Passkey signature verification failed") from exc
344
+ old_count = int(creds[idx].get("sign_count") or 0)
345
+ if parsed["sign_count"] and old_count and parsed["sign_count"] <= old_count:
346
+ raise PasskeyError("Passkey sign counter did not advance")
347
+ creds[idx]["sign_count"] = parsed["sign_count"] or old_count
348
+ creds[idx]["last_used_at"] = time.time()
349
+ _save_credentials(creds)
350
+ return {"ok": True, "credential_id": cred_id}
351
+
352
+
353
+ def delete_credential(credential_id: str) -> dict[str, Any]:
354
+ creds = _load_credentials()
355
+ kept = [c for c in creds if c.get("id") != credential_id]
356
+ if len(kept) == len(creds):
357
+ raise PasskeyError("Passkey not found")
358
+ _save_credentials(kept)
359
+ return {"ok": True, "credentials": registered_credentials()}
360
+
361
+
362
+ def clear_credentials() -> None:
363
+ """Remove all registered passkeys when the user disables all auth."""
364
+ if _CREDENTIALS_FILE.exists():
365
+ _save_credentials([])