paskia 0.7.1__py3-none-any.whl

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 (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" style="background: transparent">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <script type="module" crossorigin src="/auth/assets/restricted-C0IQufuH.js"></script>
7
+ <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
8
+ <link rel="modulepreload" crossorigin href="/auth/assets/pow-2N9bxgAo.js">
9
+ <link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BLMK7-nL.js">
10
+ <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
11
+ <link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-DgdJyscT.css">
12
+ </head>
13
+ <body>
14
+ <div id="app"></div>
15
+ </body>
16
+ </html>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Access Restricted</title>
7
+ <script type="module" crossorigin src="/auth/assets/forward-Dzg-aE1C.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
9
+ <link rel="modulepreload" crossorigin href="/auth/assets/pow-2N9bxgAo.js">
10
+ <link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BLMK7-nL.js">
11
+ <link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
12
+ <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
13
+ <link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-DgdJyscT.css">
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ </body>
18
+ </html>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Complete Passkey Setup</title>
7
+ <script type="module" crossorigin src="/auth/assets/reset-C_Td1_jn.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-rKFEraYH.js">
9
+ <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
10
+ <link rel="stylesheet" crossorigin href="/auth/assets/reset-BWF4cWKR.css">
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ </body>
15
+ </html>
paskia/globals.py ADDED
@@ -0,0 +1,71 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from paskia.db import DatabaseInterface
4
+ from paskia.sansio import Passkey
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class Manager(Generic[T]):
10
+ """Generic manager for global instances."""
11
+
12
+ def __init__(self, name: str):
13
+ self._instance: T | None = None
14
+ self._name = name
15
+
16
+ @property
17
+ def instance(self) -> T:
18
+ if self._instance is None:
19
+ raise RuntimeError(
20
+ f"{self._name} not initialized. Call globals.init() first."
21
+ )
22
+ return self._instance
23
+
24
+ @instance.setter
25
+ def instance(self, instance: T) -> None:
26
+ self._instance = instance
27
+
28
+
29
+ async def init(
30
+ rp_id: str = "localhost",
31
+ rp_name: str | None = None,
32
+ origins: list[str] | None = None,
33
+ *,
34
+ bootstrap: bool = True,
35
+ ) -> None:
36
+ """Initialize global passkey + database.
37
+
38
+ If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
39
+ In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
40
+ since the CLI performs it once before servers start.
41
+ """
42
+ from . import remoteauth
43
+
44
+ # Initialize passkey instance with provided parameters
45
+ passkey.instance = Passkey(
46
+ rp_id=rp_id,
47
+ rp_name=rp_name or rp_id,
48
+ origins=origins,
49
+ )
50
+
51
+ # Test if we have a database already initialized, otherwise use SQL
52
+ try:
53
+ db.instance
54
+ except RuntimeError:
55
+ from .db import sql
56
+
57
+ await sql.init()
58
+
59
+ # Initialize remote auth manager
60
+ await remoteauth.init()
61
+
62
+ if bootstrap:
63
+ # Bootstrap system if needed
64
+ from .bootstrap import bootstrap_if_needed
65
+
66
+ await bootstrap_if_needed()
67
+
68
+
69
+ # Global instances
70
+ passkey = Manager[Passkey]("Passkey")
71
+ db = Manager[DatabaseInterface]("Database")
paskia/remoteauth.py ADDED
@@ -0,0 +1,359 @@
1
+ """
2
+ Cross-device (remote) authentication support.
3
+
4
+ This module manages the flow for authenticating from another device:
5
+ 1. Device A (requesting) creates a remote auth request and displays QR/link
6
+ 2. Device B (authenticating) opens the link and authenticates with passkey
7
+ 3. Device A receives the session via WebSocket notification
8
+
9
+ Alternative flow (initiated from profile/authenticating device):
10
+ 1. Device A (requesting) creates request and displays short pairing code
11
+ 2. Device B (authenticating) enters the pairing code in their profile
12
+ 3. Device B authenticates, Device A receives the session
13
+
14
+ The requests are stored in-memory with short expiration (5 minutes).
15
+ The link uses the same /{token} endpoint as reset tokens, but the server
16
+ distinguishes between them by checking if the token exists in remoteauth first.
17
+ The first 3 words of the token serve as the pairing code for manual entry.
18
+ """
19
+
20
+ import asyncio
21
+ import logging
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Callable
25
+ from uuid import UUID
26
+
27
+ from paskia.util import passphrase
28
+
29
+ # Remote auth requests expire after this duration
30
+ REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
31
+
32
+
33
+ @dataclass
34
+ class RemoteAuthRequest:
35
+ """A pending remote authentication request."""
36
+
37
+ key: str # The 3-word passphrase code
38
+ created_at: datetime
39
+ host: str # The host where the session should be created
40
+ ip: str # IP of the requesting device
41
+ user_agent: str # User agent of the requesting device
42
+ action: str = "login" # "login" or "register"
43
+ locked: bool = False # True once the authenticating device has entered the code
44
+ # Callback to notify the requesting device when auth completes
45
+ # Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
46
+ notify: (
47
+ Callable[[str | None, UUID | None, UUID | None, str | None], None] | None
48
+ ) = None
49
+ # Callback to notify the requesting device when action is locked
50
+ # Takes (action) to confirm what action was locked
51
+ action_locked_notify: Callable[[str], None] | None = None
52
+ # Set when authentication completes
53
+ completed: bool = False
54
+ denied: bool = False # True if explicitly denied by the authenticating device
55
+ session_token: str | None = None
56
+ user_uuid: UUID | None = None
57
+ credential_uuid: UUID | None = None
58
+ reset_token: str | None = None
59
+
60
+
61
+ class RemoteAuthManager:
62
+ """Manages pending remote authentication requests."""
63
+
64
+ def __init__(self):
65
+ self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
66
+ self._cleanup_task: asyncio.Task | None = None
67
+ self._lock = asyncio.Lock()
68
+
69
+ async def start(self):
70
+ """Start the cleanup background task."""
71
+ if self._cleanup_task is None:
72
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
73
+
74
+ async def stop(self):
75
+ """Stop the cleanup background task."""
76
+ if self._cleanup_task:
77
+ self._cleanup_task.cancel()
78
+ try:
79
+ await self._cleanup_task
80
+ except asyncio.CancelledError:
81
+ pass
82
+ self._cleanup_task = None
83
+
84
+ async def _cleanup_loop(self):
85
+ """Periodically clean up expired requests."""
86
+ while True:
87
+ try:
88
+ await asyncio.sleep(60) # Check every minute
89
+ await self._cleanup_expired()
90
+ except asyncio.CancelledError:
91
+ break
92
+ except Exception:
93
+ logging.exception("Error in remote auth cleanup loop")
94
+
95
+ async def _cleanup_expired(self):
96
+ """Remove expired requests and notify waiting clients."""
97
+ now = datetime.now(timezone.utc)
98
+ expired_keys = []
99
+ async with self._lock:
100
+ for key, req in self._requests.items():
101
+ if now > req.created_at + REMOTE_AUTH_LIFETIME:
102
+ expired_keys.append(key)
103
+ for key in expired_keys:
104
+ req = self._requests.pop(key)
105
+ if req.notify and not req.completed:
106
+ try:
107
+ req.notify(None, None, None, None)
108
+ except Exception:
109
+ pass
110
+
111
+ async def create_request(
112
+ self,
113
+ host: str,
114
+ ip: str,
115
+ user_agent: str,
116
+ action: str = "login",
117
+ ) -> tuple[str, datetime]:
118
+ """Create a new remote auth request.
119
+
120
+ The code is a 3-word passphrase.
121
+ We ensure uniqueness across concurrent requests.
122
+
123
+ Returns:
124
+ (code, expiry) - The 3-word passphrase code and expiration time
125
+ """
126
+ now = datetime.now(timezone.utc)
127
+ expiry = now + REMOTE_AUTH_LIFETIME
128
+
129
+ async with self._lock:
130
+ # Generate unique 3-word code
131
+ max_attempts = 100
132
+ for _ in range(max_attempts):
133
+ code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
134
+ if code not in self._requests:
135
+ break
136
+ else:
137
+ # Extremely unlikely but handle gracefully
138
+ raise ValueError("Unable to generate unique code")
139
+
140
+ request = RemoteAuthRequest(
141
+ key=code,
142
+ created_at=now,
143
+ host=host,
144
+ ip=ip,
145
+ user_agent=user_agent,
146
+ action=action,
147
+ )
148
+
149
+ self._requests[code] = request
150
+
151
+ return code, expiry
152
+
153
+ async def get_request(self, code: str) -> RemoteAuthRequest | None:
154
+ """Get a pending request by code, if valid and not expired."""
155
+ # Normalize: lowercase, dot-separated words
156
+ normalized = code.lower().strip().replace(" ", ".")
157
+ if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
158
+ return None
159
+ async with self._lock:
160
+ req = self._requests.get(normalized)
161
+ if req is None:
162
+ return None
163
+ now = datetime.now(timezone.utc)
164
+ if now > req.created_at + REMOTE_AUTH_LIFETIME:
165
+ # Expired
166
+ del self._requests[normalized]
167
+ return None
168
+ return req
169
+
170
+ async def set_notify_callback(
171
+ self,
172
+ token: str,
173
+ callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
174
+ ) -> bool:
175
+ """Set the notification callback for a request.
176
+
177
+ Returns True if the request exists and callback was set.
178
+ """
179
+ async with self._lock:
180
+ req = self._requests.get(token)
181
+ if req is None:
182
+ return False
183
+ req.notify = callback
184
+ return True
185
+
186
+ async def set_action_locked_callback(
187
+ self,
188
+ token: str,
189
+ callback: Callable[[str], None],
190
+ ) -> bool:
191
+ """Set the callback for when the action is locked.
192
+
193
+ Returns True if the request exists and callback was set.
194
+ """
195
+ async with self._lock:
196
+ req = self._requests.get(token)
197
+ if req is None:
198
+ return False
199
+ req.action_locked_notify = callback
200
+ return True
201
+
202
+ async def update_action(
203
+ self,
204
+ token: str,
205
+ action: str,
206
+ ) -> bool:
207
+ """Update the action for a request (only if not locked).
208
+
209
+ Returns True if the request exists and was updated.
210
+ """
211
+ if action not in ("login", "register"):
212
+ return False
213
+ async with self._lock:
214
+ req = self._requests.get(token)
215
+ if req is None or req.locked:
216
+ return False
217
+ req.action = action
218
+ return True
219
+
220
+ async def lock_action(
221
+ self,
222
+ token: str,
223
+ ) -> str | None:
224
+ """Lock the action for a request (called when authenticating device enters code).
225
+
226
+ Returns the locked action, or None if request doesn't exist or is already locked.
227
+ Notifies the requesting device via action_locked_notify callback.
228
+ """
229
+ async with self._lock:
230
+ req = self._requests.get(token)
231
+ if req is None:
232
+ return None
233
+ if req.locked:
234
+ # Already locked by another authenticating device
235
+ return None
236
+ req.locked = True
237
+ action = req.action
238
+ if req.action_locked_notify:
239
+ try:
240
+ req.action_locked_notify(action)
241
+ except Exception:
242
+ pass
243
+ return action
244
+
245
+ async def complete_request(
246
+ self,
247
+ token: str,
248
+ session_token: str | None,
249
+ user_uuid: UUID,
250
+ credential_uuid: UUID,
251
+ reset_token: str | None = None,
252
+ ) -> bool:
253
+ """Mark a request as completed with the authentication result.
254
+
255
+ The request is removed after notifying the waiting client.
256
+ Returns True if the request existed and was completed.
257
+ """
258
+ async with self._lock:
259
+ req = self._requests.pop(token, None)
260
+ if req is None:
261
+ return False
262
+ if req.notify:
263
+ try:
264
+ req.notify(session_token, user_uuid, credential_uuid, reset_token)
265
+ except Exception:
266
+ pass
267
+ return True
268
+
269
+ async def cancel_request(
270
+ self, token: str, *, denied: bool = False
271
+ ) -> RemoteAuthRequest | None:
272
+ """Cancel and remove a request.
273
+
274
+ Args:
275
+ token: The request token
276
+ denied: If True, marks this as an explicit denial (not just timeout/disconnect)
277
+
278
+ Returns the removed request if it existed, None otherwise.
279
+ """
280
+ async with self._lock:
281
+ req = self._requests.pop(token, None)
282
+ if req is None:
283
+ return None
284
+ if denied:
285
+ req.denied = True
286
+ if req.notify and not req.completed:
287
+ try:
288
+ # Pass denied status through a special UUID value (all zeros means denied)
289
+ if denied:
290
+ req.notify(None, UUID(int=0), None, None)
291
+ else:
292
+ req.notify(None, None, None, None)
293
+ except Exception:
294
+ pass
295
+ return req
296
+
297
+ def get_connection_count(self) -> int:
298
+ """Get the current count of open WebSocket connections.
299
+
300
+ This is used to determine PoW difficulty based on load.
301
+ """
302
+ # Count is maintained externally by the WebSocket endpoints
303
+ return getattr(self, "_ws_count", 0)
304
+
305
+ def increment_connections(self) -> None:
306
+ """Increment the WebSocket connection counter."""
307
+ self._ws_count = getattr(self, "_ws_count", 0) + 1
308
+
309
+ def decrement_connections(self) -> None:
310
+ """Decrement the WebSocket connection counter."""
311
+ self._ws_count = max(0, getattr(self, "_ws_count", 0) - 1)
312
+
313
+ def get_pow_difficulty(self) -> int:
314
+ """Get PoW difficulty based on current WebSocket connection count.
315
+
316
+ Uses NORMAL difficulty with low load (< 10 connections),
317
+ HARD difficulty with high load (>= 10 connections).
318
+
319
+ Returns:
320
+ PoW work units (pow.NORMAL or pow.HARD)
321
+ """
322
+ from paskia.util import pow
323
+
324
+ count = self.get_connection_count()
325
+ return pow.HARD if count >= 10 else pow.NORMAL
326
+
327
+ async def consume_request(self, token: str) -> RemoteAuthRequest | None:
328
+ """Get and remove a request (for use by the authenticating device)."""
329
+ if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
330
+ return None
331
+ async with self._lock:
332
+ req = self._requests.get(token)
333
+ if req is None:
334
+ return None
335
+ now = datetime.now(timezone.utc)
336
+ if now > req.created_at + REMOTE_AUTH_LIFETIME:
337
+ del self._requests[token]
338
+ return None
339
+ # Don't remove yet - wait until completion
340
+ return req
341
+
342
+
343
+ # Global instance
344
+ instance: RemoteAuthManager | None = None
345
+
346
+
347
+ async def init():
348
+ """Initialize the global remote auth manager."""
349
+ global instance
350
+ instance = RemoteAuthManager()
351
+ await instance.start()
352
+
353
+
354
+ async def shutdown():
355
+ """Shutdown the global remote auth manager."""
356
+ global instance
357
+ if instance:
358
+ await instance.stop()
359
+ instance = None