paskia 0.8.0__py3-none-any.whl → 0.8.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.
- paskia/_version.py +2 -2
- paskia/config.py +0 -1
- paskia/db/__init__.py +1 -0
- paskia/fastapi/__main__.py +82 -178
- paskia/fastapi/admin.py +2 -2
- paskia/fastapi/api.py +2 -2
- paskia/fastapi/mainapp.py +18 -14
- {paskia-0.8.0.dist-info → paskia-0.8.1.dist-info}/METADATA +3 -2
- {paskia-0.8.0.dist-info → paskia-0.8.1.dist-info}/RECORD +11 -24
- paskia/util/frontend.py +0 -75
- paskia/util/hostutil.py +0 -76
- paskia/util/htmlutil.py +0 -47
- paskia/util/passphrase.py +0 -20
- paskia/util/permutil.py +0 -43
- paskia/util/pow.py +0 -45
- paskia/util/querysafe.py +0 -11
- paskia/util/sessionutil.py +0 -38
- paskia/util/startupbox.py +0 -75
- paskia/util/timeutil.py +0 -47
- paskia/util/useragent.py +0 -10
- paskia/util/userinfo.py +0 -145
- paskia/util/wordlist.py +0 -54
- {paskia-0.8.0.dist-info → paskia-0.8.1.dist-info}/WHEEL +0 -0
- {paskia-0.8.0.dist-info → paskia-0.8.1.dist-info}/entry_points.txt +0 -0
paskia/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.8.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 8,
|
|
31
|
+
__version__ = version = '0.8.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 8, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
paskia/config.py
CHANGED
paskia/db/__init__.py
CHANGED
paskia/fastapi/__main__.py
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import asyncio
|
|
3
|
-
import
|
|
3
|
+
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
from urllib.parse import urlparse
|
|
7
7
|
|
|
8
8
|
import uvicorn
|
|
9
|
-
|
|
9
|
+
from fastapi_vue.hostutil import parse_endpoint
|
|
10
|
+
from uvicorn import Config, Server
|
|
11
|
+
|
|
12
|
+
from paskia import globals as _globals
|
|
13
|
+
from paskia.bootstrap import bootstrap_if_needed
|
|
14
|
+
from paskia.config import PaskiaConfig
|
|
15
|
+
from paskia.fastapi import app as fastapi_app
|
|
16
|
+
from paskia.fastapi import reset as reset_cmd
|
|
17
|
+
from paskia.util import startupbox
|
|
10
18
|
from paskia.util.hostutil import normalize_origin
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
DEFAULT_PORT = 4401
|
|
21
|
+
|
|
22
|
+
EPILOG = """\
|
|
23
|
+
Examples:
|
|
24
|
+
paskia # localhost:4401
|
|
25
|
+
paskia :8080 # All interfaces, port 8080
|
|
26
|
+
paskia unix:/tmp/paskia.sock
|
|
27
|
+
paskia reset [user] # Generate passkey reset link
|
|
28
|
+
"""
|
|
14
29
|
|
|
15
30
|
|
|
16
31
|
def is_subdomain(sub: str, domain: str) -> bool:
|
|
@@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
|
|
34
49
|
)
|
|
35
50
|
|
|
36
51
|
|
|
37
|
-
def parse_endpoint(
|
|
38
|
-
value: str | None, default_port: int
|
|
39
|
-
) -> tuple[str | None, int | None, str | None, bool]:
|
|
40
|
-
"""Parse an endpoint using stdlib (urllib.parse, ipaddress).
|
|
41
|
-
|
|
42
|
-
Returns (host, port, uds_path). If uds_path is not None, host/port are None.
|
|
43
|
-
|
|
44
|
-
Supported forms:
|
|
45
|
-
- host[:port]
|
|
46
|
-
- :port (uses default host)
|
|
47
|
-
- [ipv6][:port] (bracketed for port usage)
|
|
48
|
-
- ipv6 (unbracketed, no port allowed -> default port)
|
|
49
|
-
- unix:/path/to/socket.sock
|
|
50
|
-
- None -> defaults (localhost:4401)
|
|
51
|
-
|
|
52
|
-
Notes:
|
|
53
|
-
- For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
|
|
54
|
-
- Unbracketed IPv6 like ::1 implies the default port.
|
|
55
|
-
"""
|
|
56
|
-
if not value:
|
|
57
|
-
return DEFAULT_HOST, default_port, None, False
|
|
58
|
-
|
|
59
|
-
# Port only (numeric) -> localhost:port
|
|
60
|
-
if value.isdigit():
|
|
61
|
-
try:
|
|
62
|
-
port_only = int(value)
|
|
63
|
-
except ValueError: # pragma: no cover (isdigit guards)
|
|
64
|
-
raise SystemExit(f"Invalid port '{value}'")
|
|
65
|
-
return DEFAULT_HOST, port_only, None, False
|
|
66
|
-
|
|
67
|
-
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
|
68
|
-
if value.startswith(":") and value != ":":
|
|
69
|
-
port_part = value[1:]
|
|
70
|
-
if not port_part.isdigit():
|
|
71
|
-
raise SystemExit(f"Invalid port in '{value}'")
|
|
72
|
-
return None, int(port_part), None, True
|
|
73
|
-
|
|
74
|
-
# UNIX domain socket
|
|
75
|
-
if value.startswith("unix:"):
|
|
76
|
-
uds_path = value[5:] or None
|
|
77
|
-
if uds_path is None:
|
|
78
|
-
raise SystemExit("unix: path must not be empty")
|
|
79
|
-
return None, None, uds_path, False
|
|
80
|
-
|
|
81
|
-
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
|
82
|
-
if value.count(":") > 1 and not value.startswith("["):
|
|
83
|
-
try:
|
|
84
|
-
ipaddress.IPv6Address(value)
|
|
85
|
-
except ValueError as e: # pragma: no cover
|
|
86
|
-
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
|
87
|
-
return value, default_port, None, False
|
|
88
|
-
|
|
89
|
-
# Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
|
|
90
|
-
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
|
91
|
-
host = parsed.hostname
|
|
92
|
-
port = parsed.port
|
|
93
|
-
|
|
94
|
-
# Host may be None if empty (e.g. ':5500')
|
|
95
|
-
if not host:
|
|
96
|
-
host = DEFAULT_HOST
|
|
97
|
-
if port is None:
|
|
98
|
-
port = default_port
|
|
99
|
-
|
|
100
|
-
# Validate IP literals (optional; hostname passes through)
|
|
101
|
-
try:
|
|
102
|
-
# Strip brackets if somehow present (urlparse removes them already)
|
|
103
|
-
ipaddress.ip_address(host)
|
|
104
|
-
except ValueError:
|
|
105
|
-
# Not an IP address -> treat as hostname; no action
|
|
106
|
-
pass
|
|
107
|
-
|
|
108
|
-
return host, port, None, False
|
|
109
|
-
|
|
110
|
-
|
|
111
52
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
|
112
53
|
p.add_argument(
|
|
113
54
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
|
@@ -134,45 +75,44 @@ def main():
|
|
|
134
75
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
135
76
|
|
|
136
77
|
parser = argparse.ArgumentParser(
|
|
137
|
-
prog="paskia",
|
|
78
|
+
prog="paskia",
|
|
79
|
+
description="Paskia authentication server",
|
|
80
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
81
|
+
epilog=EPILOG,
|
|
138
82
|
)
|
|
139
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
83
|
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
"serve", help="Run the server (production style, no auto-reload)"
|
|
144
|
-
)
|
|
145
|
-
serve.add_argument(
|
|
84
|
+
# Primary argument: either host:port or "reset" subcommand
|
|
85
|
+
parser.add_argument(
|
|
146
86
|
"hostport",
|
|
147
87
|
nargs="?",
|
|
148
88
|
help=(
|
|
149
89
|
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
|
150
|
-
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
|
151
|
-
),
|
|
152
|
-
)
|
|
153
|
-
add_common_options(serve)
|
|
154
|
-
|
|
155
|
-
# reset subcommand
|
|
156
|
-
reset = sub.add_parser(
|
|
157
|
-
"reset",
|
|
158
|
-
help=(
|
|
159
|
-
"Create a credential reset link for a user. Provide part of the display name or UUID. "
|
|
160
|
-
"If omitted, targets the master admin (first Administration role user in an auth:admin org)."
|
|
90
|
+
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
|
|
161
91
|
),
|
|
162
92
|
)
|
|
163
|
-
|
|
164
|
-
"
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"reset_query",
|
|
165
95
|
nargs="?",
|
|
166
|
-
help="
|
|
96
|
+
help="For 'reset' command: user UUID or substring of display name",
|
|
167
97
|
)
|
|
168
|
-
add_common_options(
|
|
98
|
+
add_common_options(parser)
|
|
169
99
|
|
|
170
100
|
args = parser.parse_args()
|
|
171
101
|
|
|
172
|
-
|
|
173
|
-
|
|
102
|
+
# Detect "reset" subcommand (first positional is "reset")
|
|
103
|
+
is_reset = args.hostport == "reset"
|
|
104
|
+
|
|
105
|
+
if is_reset:
|
|
106
|
+
endpoints = []
|
|
174
107
|
else:
|
|
175
|
-
|
|
108
|
+
# Parse endpoint using fastapi_vue.hostutil
|
|
109
|
+
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
|
|
110
|
+
|
|
111
|
+
# Extract host/port/uds from first endpoint for config display and site_url
|
|
112
|
+
ep = endpoints[0] if endpoints else {}
|
|
113
|
+
host = ep.get("host")
|
|
114
|
+
port = ep.get("port")
|
|
115
|
+
uds = ep.get("uds")
|
|
176
116
|
|
|
177
117
|
# Collect and normalize origins, handle auth_host
|
|
178
118
|
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
|
@@ -193,8 +133,13 @@ def main():
|
|
|
193
133
|
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
|
194
134
|
|
|
195
135
|
# Compute site_url and site_path for reset links
|
|
196
|
-
# Priority: auth_host > first origin with localhost > http://localhost:port
|
|
197
|
-
|
|
136
|
+
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
|
137
|
+
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
|
138
|
+
if explicit_site_url:
|
|
139
|
+
# Explicit site URL from devserver or deployment config
|
|
140
|
+
site_url = explicit_site_url.rstrip("/")
|
|
141
|
+
site_path = "/" if args.auth_host else "/auth/"
|
|
142
|
+
elif args.auth_host:
|
|
198
143
|
site_url = args.auth_host.rstrip("/")
|
|
199
144
|
site_path = "/"
|
|
200
145
|
elif origins:
|
|
@@ -215,8 +160,6 @@ def main():
|
|
|
215
160
|
site_path = "/auth/"
|
|
216
161
|
|
|
217
162
|
# Build runtime configuration
|
|
218
|
-
from paskia.config import PaskiaConfig
|
|
219
|
-
|
|
220
163
|
config = PaskiaConfig(
|
|
221
164
|
rp_id=args.rp_id,
|
|
222
165
|
rp_name=args.rp_name or None,
|
|
@@ -230,8 +173,6 @@ def main():
|
|
|
230
173
|
)
|
|
231
174
|
|
|
232
175
|
# Export configuration via single JSON env variable for worker processes
|
|
233
|
-
import json
|
|
234
|
-
|
|
235
176
|
config_json = {
|
|
236
177
|
"rp_id": config.rp_id,
|
|
237
178
|
"rp_name": config.rp_name,
|
|
@@ -243,8 +184,6 @@ def main():
|
|
|
243
184
|
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
|
244
185
|
|
|
245
186
|
# Initialize globals (without bootstrap yet)
|
|
246
|
-
from paskia import globals as _globals # local import
|
|
247
|
-
|
|
248
187
|
asyncio.run(
|
|
249
188
|
_globals.init(
|
|
250
189
|
rp_id=config.rp_id,
|
|
@@ -255,80 +194,45 @@ def main():
|
|
|
255
194
|
)
|
|
256
195
|
|
|
257
196
|
# Print startup configuration
|
|
258
|
-
from paskia.util import startupbox
|
|
259
|
-
|
|
260
197
|
startupbox.print_startup_config(config)
|
|
261
198
|
|
|
262
199
|
# Bootstrap after startup box is printed
|
|
263
|
-
from paskia.bootstrap import bootstrap_if_needed
|
|
264
|
-
|
|
265
200
|
asyncio.run(bootstrap_if_needed())
|
|
266
201
|
|
|
267
|
-
# Handle
|
|
268
|
-
if
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
exit_code = reset_cmd.run(getattr(args, "query", None))
|
|
202
|
+
# Handle reset command (no server start)
|
|
203
|
+
if is_reset:
|
|
204
|
+
exit_code = reset_cmd.run(args.reset_query)
|
|
272
205
|
raise SystemExit(exit_code)
|
|
273
206
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
|
304
|
-
else:
|
|
305
|
-
# Production: run separate servers for IPv4 and IPv6
|
|
306
|
-
from uvicorn import Config, Server # noqa: E402 local import
|
|
307
|
-
|
|
308
|
-
from paskia.fastapi import (
|
|
309
|
-
app as fastapi_app, # noqa: E402 local import
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
async def serve_both():
|
|
313
|
-
servers = []
|
|
314
|
-
assert port is not None
|
|
315
|
-
for h in ("0.0.0.0", "::"):
|
|
316
|
-
try:
|
|
317
|
-
cfg = Config(
|
|
318
|
-
app=fastapi_app,
|
|
319
|
-
host=h,
|
|
320
|
-
port=port,
|
|
321
|
-
log_level="info",
|
|
322
|
-
)
|
|
323
|
-
servers.append(Server(cfg))
|
|
324
|
-
except Exception as e: # pragma: no cover
|
|
325
|
-
logging.warning(f"Failed to configure server for {h}: {e}")
|
|
326
|
-
tasks = [asyncio.create_task(s.serve()) for s in servers]
|
|
327
|
-
await asyncio.gather(*tasks)
|
|
328
|
-
|
|
329
|
-
asyncio.run(serve_both())
|
|
330
|
-
else:
|
|
331
|
-
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
|
207
|
+
# Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
|
|
208
|
+
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
|
209
|
+
|
|
210
|
+
run_kwargs: dict = {
|
|
211
|
+
"log_level": "info",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if devmode:
|
|
215
|
+
# Security: dev mode must run on localhost:4402 to prevent
|
|
216
|
+
# accidental public exposure of the Vite dev server
|
|
217
|
+
if host != "localhost" or port != 4402:
|
|
218
|
+
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
219
|
+
run_kwargs["reload"] = True
|
|
220
|
+
run_kwargs["reload_dirs"] = ["paskia"]
|
|
221
|
+
# Suppress uvicorn startup messages in dev mode
|
|
222
|
+
run_kwargs["log_level"] = "warning"
|
|
223
|
+
|
|
224
|
+
if len(endpoints) > 1:
|
|
225
|
+
# Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
|
|
226
|
+
async def serve_all():
|
|
227
|
+
async with asyncio.TaskGroup() as tg:
|
|
228
|
+
for ep in endpoints:
|
|
229
|
+
tg.create_task(
|
|
230
|
+
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
asyncio.run(serve_all())
|
|
234
|
+
else:
|
|
235
|
+
uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
|
332
236
|
|
|
333
237
|
|
|
334
238
|
if __name__ == "__main__":
|
paskia/fastapi/admin.py
CHANGED
|
@@ -10,12 +10,12 @@ from paskia.authsession import EXPIRES, reset_expires
|
|
|
10
10
|
from paskia.fastapi import authz
|
|
11
11
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
12
12
|
from paskia.util import (
|
|
13
|
-
frontend,
|
|
14
13
|
hostutil,
|
|
15
14
|
passphrase,
|
|
16
15
|
permutil,
|
|
17
16
|
querysafe,
|
|
18
17
|
useragent,
|
|
18
|
+
vitedev,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
app = FastAPI()
|
|
@@ -77,7 +77,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
|
|
|
77
77
|
|
|
78
78
|
@app.get("/")
|
|
79
79
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
|
80
|
-
return Response(*await
|
|
80
|
+
return Response(*await vitedev.read("/auth/admin/index.html"))
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
# -------------------- Organizations --------------------
|
paskia/fastapi/api.py
CHANGED
|
@@ -23,7 +23,7 @@ from paskia.authsession import (
|
|
|
23
23
|
from paskia.fastapi import authz, session, user
|
|
24
24
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
|
25
25
|
from paskia.globals import passkey as global_passkey
|
|
26
|
-
from paskia.util import
|
|
26
|
+
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
|
27
27
|
|
|
28
28
|
bearer_auth = HTTPBearer(auto_error=True)
|
|
29
29
|
|
|
@@ -180,7 +180,7 @@ async def forward_authentication(
|
|
|
180
180
|
if wants_html:
|
|
181
181
|
# Browser request - return full-page HTML with metadata
|
|
182
182
|
data_attrs = {"mode": e.mode, **e.metadata}
|
|
183
|
-
html = (await
|
|
183
|
+
html = (await vitedev.read("/int/forward/index.html"))[0]
|
|
184
184
|
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
|
185
185
|
return Response(
|
|
186
186
|
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
paskia/fastapi/mainapp.py
CHANGED
|
@@ -5,11 +5,18 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
7
7
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
8
|
-
from
|
|
8
|
+
from fastapi_vue import Frontend
|
|
9
9
|
|
|
10
10
|
from paskia.fastapi import admin, api, auth_host, ws
|
|
11
11
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
12
|
-
from paskia.util import
|
|
12
|
+
from paskia.util import hostutil, passphrase, vitedev
|
|
13
|
+
|
|
14
|
+
# Vue Frontend static files
|
|
15
|
+
frontend = Frontend(
|
|
16
|
+
Path(__file__).with_name("frontend-build"),
|
|
17
|
+
cached=["/auth/assets/"],
|
|
18
|
+
)
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
# Path to examples/index.html when running from source tree
|
|
15
22
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
|
@@ -43,10 +50,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
43
50
|
raise
|
|
44
51
|
|
|
45
52
|
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
|
46
|
-
if frontend.
|
|
53
|
+
if frontend.devmode:
|
|
47
54
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
|
48
55
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
|
49
56
|
|
|
57
|
+
await frontend.load()
|
|
50
58
|
yield
|
|
51
59
|
|
|
52
60
|
|
|
@@ -59,19 +67,11 @@ app.mount("/auth/api/admin/", admin.app)
|
|
|
59
67
|
app.mount("/auth/api/", api.app)
|
|
60
68
|
app.mount("/auth/ws/", ws.app)
|
|
61
69
|
|
|
62
|
-
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
|
63
|
-
if not frontend.is_dev_mode():
|
|
64
|
-
app.mount(
|
|
65
|
-
"/auth/assets/",
|
|
66
|
-
StaticFiles(directory=frontend.file("auth", "assets")),
|
|
67
|
-
name="assets",
|
|
68
|
-
)
|
|
69
|
-
|
|
70
70
|
|
|
71
71
|
@app.get("/auth/restricted/")
|
|
72
72
|
async def restricted_view():
|
|
73
73
|
"""Serve the restricted/authentication UI for iframe embedding."""
|
|
74
|
-
return Response(*await
|
|
74
|
+
return Response(*await vitedev.read("/auth/restricted/index.html"))
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
|
@@ -86,7 +86,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
86
86
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
|
87
87
|
Access control is handled via APIs.
|
|
88
88
|
"""
|
|
89
|
-
return Response(*await
|
|
89
|
+
return Response(*await vitedev.read("/auth/index.html"))
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
@app.get("/admin", include_in_schema=False)
|
|
@@ -128,4 +128,8 @@ async def token_link(token: str):
|
|
|
128
128
|
if not passphrase.is_well_formed(token):
|
|
129
129
|
raise HTTPException(status_code=404)
|
|
130
130
|
|
|
131
|
-
return Response(*await
|
|
131
|
+
return Response(*await vitedev.read("/int/reset/index.html"))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Final catch-all route for frontend files (keep at end of file)
|
|
135
|
+
frontend.route(app, "/")
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paskia
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
|
|
6
6
|
Project-URL: Repository, https://github.com/LeoVasanko/paskia
|
|
7
7
|
Author: Leo Vasanko
|
|
8
8
|
Keywords: FastAPI,auth_request,forward_auth
|
|
9
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
10
|
Requires-Dist: aiofiles>=25.1.0
|
|
11
11
|
Requires-Dist: base64url>=1.0.0
|
|
12
|
+
Requires-Dist: fastapi-vue>=0.3.0
|
|
12
13
|
Requires-Dist: fastapi[standard]>=0.104.1
|
|
13
14
|
Requires-Dist: jsondiff>=2.2.1
|
|
14
15
|
Requires-Dist: msgspec>=0.20.0
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
paskia/__init__.py,sha256=6eopO87IOFA2zfOuqt8Jj8Tdtp93HBMOgUBtTzMRweM,57
|
|
2
|
-
paskia/_version.py,sha256=
|
|
2
|
+
paskia/_version.py,sha256=3xUKhXexSTpGjDP__-RnFbAaEHHySzOSJX6zocN6QZU,704
|
|
3
3
|
paskia/authsession.py,sha256=Do0L2b_i3zUKvRcf4p45xeCwu5ZXAwdtEPr5H8y6X7M,2305
|
|
4
4
|
paskia/bootstrap.py,sha256=5p4kAfTeWbcM-JD6gnmKMUilhPSRIH8HyZtnVevt8Co,5764
|
|
5
|
-
paskia/config.py,sha256=
|
|
5
|
+
paskia/config.py,sha256=BdGzQ3Ja1enSTHmkDkBDGQk_JluT3VaK3Y7AqB5xMlk,723
|
|
6
6
|
paskia/globals.py,sha256=YQQHDr2nCicgCSWyIKOj_vL33c7uHTCi94cKXk6y2iI,1713
|
|
7
7
|
paskia/remoteauth.py,sha256=peu_fcINwDW8YGzLtbMq7FX7oD05bWwNLmJaMYWaKR0,12481
|
|
8
8
|
paskia/sansio.py,sha256=O4TGgNrHAYtHR2IxRgyrB2P2Zesz7eGq-IMM1RGjvC8,9998
|
|
9
9
|
paskia/aaguid/__init__.py,sha256=Moy67wiJSYulL_whgEEBBKabKEOvlR-NRLyXprDdBO0,1007
|
|
10
10
|
paskia/aaguid/combined_aaguid.json,sha256=CaZ96AiwdAjBnyVZnJ1eolAHxUQMB2H6mDgZkorYg_A,4124722
|
|
11
|
-
paskia/db/__init__.py,sha256=
|
|
11
|
+
paskia/db/__init__.py,sha256=GzOfyLlnxsNj5i0Slhc3csasEDqsKuTgnMHNI57SMqo,4007
|
|
12
12
|
paskia/db/background.py,sha256=Wb8pWmko0CbY48NCfBkxmmPRgPaU5U_wEA4TYNPHBe8,4005
|
|
13
13
|
paskia/db/jsonl.py,sha256=CCl3Tlj73_Ej5NbiZQgEGrGqm6uLQF0bmmupoZ060tU,3913
|
|
14
14
|
paskia/db/operations.py,sha256=aOrvq8YSjRuN_Ahq7nK7iTx4Uw6uBmfnmKisC-v2cwU,41591
|
|
15
15
|
paskia/db/structs.py,sha256=ZWqAJPtixT6B5iHibss3KpDqvgzgPfP-nbSf3NGDQN4,3574
|
|
16
16
|
paskia/fastapi/__init__.py,sha256=NFsTX1qytoyZKiur7RDTa2fxiOWHrop5CAAx8rqK9E0,58
|
|
17
|
-
paskia/fastapi/__main__.py,sha256=
|
|
18
|
-
paskia/fastapi/admin.py,sha256=
|
|
19
|
-
paskia/fastapi/api.py,sha256=
|
|
17
|
+
paskia/fastapi/__main__.py,sha256=174cDU95640vh0rK_dE4Wu_pi5d273qAXwmOyYIctgg,7875
|
|
18
|
+
paskia/fastapi/admin.py,sha256=4gr09CdGScpubSajXDLxiuJUHkV4hqIINffY3wxaZhs,36871
|
|
19
|
+
paskia/fastapi/api.py,sha256=yzI24hPKsgw-UrvEDphtB25S6NUM-9fyfz_JaQ4_PHI,10652
|
|
20
20
|
paskia/fastapi/auth_host.py,sha256=Y5w9Mz6jyq0hj7SX8LfwebaesUOLGcWzGW9lsmw5WOo,3242
|
|
21
21
|
paskia/fastapi/authz.py,sha256=6s2TGkb3C7qWTXHOaMj613NiqMfztqM5QENuSb2IjO8,3529
|
|
22
|
-
paskia/fastapi/mainapp.py,sha256=
|
|
22
|
+
paskia/fastapi/mainapp.py,sha256=nvUlKNzwHQZNhoXFVmGj9Aj2kFjklzdSNzJw8pjnimY,4442
|
|
23
23
|
paskia/fastapi/remote.py,sha256=Qwplai_bK65WqxRA3btV_Psrl_bDF2VHN0Yzh5vwB78,19783
|
|
24
24
|
paskia/fastapi/reset.py,sha256=8rhtqU_L17i_ZrIPPz7cXi_laWfM9_daNSnG2gS93b8,3341
|
|
25
25
|
paskia/fastapi/session.py,sha256=9n0NuK5NtocOb27ahIU2T9eHZUog1Og_i9vvaDf7Wo8,1489
|
|
@@ -49,20 +49,7 @@ paskia/frontend-build/int/forward/index.html,sha256=h4qLmAjIlGQ7ZjKV3yDsMD4SpPS2
|
|
|
49
49
|
paskia/frontend-build/int/reset/index.html,sha256=t21RLLETyTHBnKe95V053_zmfB34Vavp0fp4G5fU7-U,612
|
|
50
50
|
paskia/migrate/__init__.py,sha256=4ZpfZbPBeJHi1Pjtq76bOi8Ev4hFDwZNXIQNuqQ3p5c,9803
|
|
51
51
|
paskia/migrate/sql.py,sha256=qiAQooYVYdFwW-UeMwNnjLOjVAFa1tjHCZFjkkuinp0,12782
|
|
52
|
-
paskia/
|
|
53
|
-
paskia/
|
|
54
|
-
paskia/
|
|
55
|
-
paskia/
|
|
56
|
-
paskia/util/permutil.py,sha256=-pjC54SUXy6G-wET8hXtlEOhsiHY8ukpt_-lVBxu9T0,1388
|
|
57
|
-
paskia/util/pow.py,sha256=u99Phs1DBiv9Ptm8agaA7ZSOnRPtDcpgkLgGzNTcJWo,1395
|
|
58
|
-
paskia/util/querysafe.py,sha256=iHfY2z5MX0Y6gso2oeq-SiHhg97vq35Jp1D16sXY0FE,294
|
|
59
|
-
paskia/util/sessionutil.py,sha256=nhm2OVOrWQ8tskHEBSLrLrZjG6-jQdvbfqGyib2mZ50,1263
|
|
60
|
-
paskia/util/startupbox.py,sha256=SIg26g9EIdkm7uVWiDfAUuXn6Z05EAsYEQni4qkVEWI,2353
|
|
61
|
-
paskia/util/timeutil.py,sha256=1Zf8rXa76oLXDuZrGyuVDNhFjxl-28Z_Xb6pIH06ORk,1258
|
|
62
|
-
paskia/util/useragent.py,sha256=wOeueToxKHdJ91vT5jMVBoIhelNwxD5u7vgWQGSjImA,325
|
|
63
|
-
paskia/util/userinfo.py,sha256=b3dP_EJ94x76UAclq3y1rwvo3hIa65JM6B3mTRoZvJ8,4600
|
|
64
|
-
paskia/util/wordlist.py,sha256=EKwrABkqRO1f59--muegOoluPydPJAHlWJNXwV0IFyA,6069
|
|
65
|
-
paskia-0.8.0.dist-info/METADATA,sha256=XjqoY0zqAImymOSNsiluNfwGNtxHMdWW_9EhdeiTAQM,4227
|
|
66
|
-
paskia-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
67
|
-
paskia-0.8.0.dist-info/entry_points.txt,sha256=vvx6RYetgd61I2ODqQPHqrKHgCfuo08w_T35yDlHenE,93
|
|
68
|
-
paskia-0.8.0.dist-info/RECORD,,
|
|
52
|
+
paskia-0.8.1.dist-info/METADATA,sha256=gRL8oNIcCgmyIHkC5-mAiup9dwEdAgoMbzIbSqo6apE,4261
|
|
53
|
+
paskia-0.8.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
54
|
+
paskia-0.8.1.dist-info/entry_points.txt,sha256=vvx6RYetgd61I2ODqQPHqrKHgCfuo08w_T35yDlHenE,93
|
|
55
|
+
paskia-0.8.1.dist-info/RECORD,,
|
paskia/util/frontend.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import mimetypes
|
|
3
|
-
import os
|
|
4
|
-
from importlib import resources
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
__all__ = ["path", "file", "read", "is_dev_mode"]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _get_dev_server() -> str | None:
|
|
13
|
-
"""Get the dev server URL from environment, or None if not in dev mode."""
|
|
14
|
-
return os.environ.get("PASKIA_DEVMODE") or None
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _resolve_static_dir() -> Path:
|
|
18
|
-
# Try packaged path via importlib.resources (works for wheel/installed).
|
|
19
|
-
try: # pragma: no cover - trivial path resolution
|
|
20
|
-
pkg_dir = resources.files("paskia") / "frontend-build"
|
|
21
|
-
fs_path = Path(str(pkg_dir))
|
|
22
|
-
if fs_path.is_dir():
|
|
23
|
-
return fs_path
|
|
24
|
-
except Exception: # pragma: no cover - defensive
|
|
25
|
-
pass
|
|
26
|
-
# Fallback for editable/development before build.
|
|
27
|
-
return Path(__file__).parent.parent / "frontend-build"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
path: Path = _resolve_static_dir()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def file(*parts: str) -> Path:
|
|
34
|
-
"""Return a child path under the static root."""
|
|
35
|
-
return path.joinpath(*parts)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def is_dev_mode() -> bool:
|
|
39
|
-
"""Check if we're running in dev mode (Vite frontend server)."""
|
|
40
|
-
return bool(_get_dev_server())
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
|
44
|
-
"""Read file content and return response tuple.
|
|
45
|
-
|
|
46
|
-
In dev mode, fetches from the Vite dev server.
|
|
47
|
-
In production, reads from the static build directory.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
Tuple of (content, status_code, headers) suitable for
|
|
54
|
-
FastAPI Response(*args) or Sanic raw response.
|
|
55
|
-
"""
|
|
56
|
-
if is_dev_mode():
|
|
57
|
-
dev_server = _get_dev_server()
|
|
58
|
-
async with httpx.AsyncClient() as client:
|
|
59
|
-
resp = await client.get(f"{dev_server}{filepath}")
|
|
60
|
-
resp.raise_for_status()
|
|
61
|
-
mime = resp.headers.get("content-type", "application/octet-stream")
|
|
62
|
-
# Strip charset suffix if present
|
|
63
|
-
mime = mime.split(";")[0].strip()
|
|
64
|
-
return resp.content, resp.status_code, {"content-type": mime}
|
|
65
|
-
else:
|
|
66
|
-
# Production: read from static build
|
|
67
|
-
file_path = path / filepath.lstrip("/")
|
|
68
|
-
content = await _read_file_async(file_path)
|
|
69
|
-
mime, _ = mimetypes.guess_type(str(file_path))
|
|
70
|
-
return content, 200, {"content-type": mime or "application/octet-stream"}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
async def _read_file_async(file_path: Path) -> bytes:
|
|
74
|
-
"""Read file asynchronously using asyncio.to_thread."""
|
|
75
|
-
return await asyncio.to_thread(file_path.read_bytes)
|
paskia/util/hostutil.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
"""Utilities for determining the auth UI host and base URLs."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from functools import lru_cache
|
|
6
|
-
from urllib.parse import urlsplit
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@lru_cache(maxsize=1)
|
|
10
|
-
def _load_config() -> dict:
|
|
11
|
-
"""Load PASKIA_CONFIG JSON."""
|
|
12
|
-
config_json = os.getenv("PASKIA_CONFIG")
|
|
13
|
-
if not config_json:
|
|
14
|
-
return {}
|
|
15
|
-
return json.loads(config_json)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def is_root_mode() -> bool:
|
|
19
|
-
return _load_config().get("auth_host") is not None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def dedicated_auth_host() -> str | None:
|
|
23
|
-
"""Return configured auth_host netloc, or None."""
|
|
24
|
-
auth_host = _load_config().get("auth_host")
|
|
25
|
-
if not auth_host:
|
|
26
|
-
return None
|
|
27
|
-
from urllib.parse import urlparse
|
|
28
|
-
|
|
29
|
-
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
|
30
|
-
return parsed.netloc or parsed.path or None
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def ui_base_path() -> str:
|
|
34
|
-
return "/" if is_root_mode() else "/auth/"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def auth_site_url() -> str:
|
|
38
|
-
"""Return the base URL for the auth site UI (computed at startup)."""
|
|
39
|
-
cfg = _load_config()
|
|
40
|
-
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def reset_link_url(token: str) -> str:
|
|
44
|
-
"""Generate a reset link URL for the given token."""
|
|
45
|
-
return f"{auth_site_url()}{token}"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def normalize_origin(origin: str) -> str:
|
|
49
|
-
"""Normalize an origin URL by adding https:// if no scheme is present."""
|
|
50
|
-
if "://" not in origin:
|
|
51
|
-
return f"https://{origin}"
|
|
52
|
-
return origin
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def reload_config() -> None:
|
|
56
|
-
_load_config.cache_clear()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def normalize_host(raw_host: str | None) -> str | None:
|
|
60
|
-
"""Normalize a Host header preserving port (exact match required)."""
|
|
61
|
-
if not raw_host:
|
|
62
|
-
return None
|
|
63
|
-
candidate = raw_host.strip()
|
|
64
|
-
if not candidate:
|
|
65
|
-
return None
|
|
66
|
-
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
|
67
|
-
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
|
68
|
-
netloc = parsed.netloc or parsed.path or ""
|
|
69
|
-
# Strip IPv6 brackets around host part but retain port suffix.
|
|
70
|
-
if netloc.startswith("["):
|
|
71
|
-
# format: [ipv6]:port or [ipv6]
|
|
72
|
-
if "]" in netloc:
|
|
73
|
-
host_part, _, rest = netloc.partition("]")
|
|
74
|
-
port_part = rest.lstrip(":")
|
|
75
|
-
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
|
76
|
-
return netloc.lower() or None
|
paskia/util/htmlutil.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Utility functions for HTML manipulation."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
|
7
|
-
"""Patch HTML by adding data attributes to the <html> tag.
|
|
8
|
-
|
|
9
|
-
If an <html> tag exists, adds data attributes to it.
|
|
10
|
-
If no <html> tag exists, prepends one with the data attributes.
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
html: The HTML content as bytes
|
|
14
|
-
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
|
15
|
-
|
|
16
|
-
Returns:
|
|
17
|
-
Modified HTML as bytes
|
|
18
|
-
|
|
19
|
-
Examples:
|
|
20
|
-
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
|
21
|
-
b'<html data-mode="reauth"><body>test</body></html>'
|
|
22
|
-
|
|
23
|
-
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
|
24
|
-
b'<html data-mode="reauth"><body>test</body>'
|
|
25
|
-
"""
|
|
26
|
-
if not data_attrs:
|
|
27
|
-
return html
|
|
28
|
-
|
|
29
|
-
html_str = html.decode("utf-8")
|
|
30
|
-
|
|
31
|
-
# Build the data attributes string
|
|
32
|
-
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
|
33
|
-
|
|
34
|
-
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
|
35
|
-
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
|
36
|
-
match = html_tag_pattern.search(html_str)
|
|
37
|
-
|
|
38
|
-
if match:
|
|
39
|
-
# Insert data attributes into existing <html> tag
|
|
40
|
-
existing_attrs = match.group(1)
|
|
41
|
-
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
|
42
|
-
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
|
43
|
-
else:
|
|
44
|
-
# Prepend <html> tag with data attributes
|
|
45
|
-
html_str = f"<html {attrs_str}>" + html_str
|
|
46
|
-
|
|
47
|
-
return html_str.encode("utf-8")
|
paskia/util/passphrase.py
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import secrets
|
|
2
|
-
|
|
3
|
-
from paskia.util.wordlist import words
|
|
4
|
-
|
|
5
|
-
N_WORDS = 5
|
|
6
|
-
N_WORDS_SHORT = 3
|
|
7
|
-
|
|
8
|
-
wset = set(words)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def generate(n=N_WORDS, sep="."):
|
|
12
|
-
"""Generate a password of random words without repeating any word."""
|
|
13
|
-
wl = words.copy()
|
|
14
|
-
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
|
|
18
|
-
"""Check if the passphrase is well-formed according to the regex pattern."""
|
|
19
|
-
p = passphrase.split(sep)
|
|
20
|
-
return len(p) == n and all(w in wset for w in passphrase.split("."))
|
paskia/util/permutil.py
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
|
2
|
-
|
|
3
|
-
from collections.abc import Sequence
|
|
4
|
-
from fnmatch import fnmatchcase
|
|
5
|
-
|
|
6
|
-
from paskia import db
|
|
7
|
-
from paskia.util.hostutil import normalize_host
|
|
8
|
-
|
|
9
|
-
__all__ = ["has_any", "has_all", "session_context"]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _match(perms: set[str], patterns: Sequence[str]):
|
|
13
|
-
return (
|
|
14
|
-
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms
|
|
15
|
-
for pat in patterns
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _get_effective_scopes(ctx) -> set[str]:
|
|
20
|
-
"""Get effective permission scopes from context.
|
|
21
|
-
|
|
22
|
-
Returns scopes from ctx.permissions (filtered by org) if available,
|
|
23
|
-
otherwise falls back to ctx.role.permissions for backwards compatibility.
|
|
24
|
-
"""
|
|
25
|
-
if ctx.permissions:
|
|
26
|
-
return {p.scope for p in ctx.permissions}
|
|
27
|
-
# Fallback for contexts without effective permissions computed
|
|
28
|
-
return set(ctx.role.permissions or [])
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def has_any(ctx, patterns: Sequence[str]) -> bool:
|
|
32
|
-
return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def has_all(ctx, patterns: Sequence[str]) -> bool:
|
|
36
|
-
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def session_context(auth: str | None, host: str | None = None):
|
|
40
|
-
if not auth:
|
|
41
|
-
return None
|
|
42
|
-
normalized_host = normalize_host(host) if host else None
|
|
43
|
-
return db.get_session_context(auth, normalized_host)
|
paskia/util/pow.py
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Proof of Work utility using PBKDF2-SHA512.
|
|
3
|
-
|
|
4
|
-
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
|
5
|
-
output with a zero first byte. Each work unit requires finding one such nonce.
|
|
6
|
-
All valid nonces are concatenated into a solution for server verification.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import hashlib
|
|
10
|
-
import secrets
|
|
11
|
-
|
|
12
|
-
EASY = 2 # Around 0.25s
|
|
13
|
-
NORMAL = 8 # Around 1s
|
|
14
|
-
HARD = 32 # Around 4s
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def generate_challenge() -> bytes:
|
|
18
|
-
"""Generate a random 8-byte challenge."""
|
|
19
|
-
return secrets.token_bytes(8)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
|
|
23
|
-
"""Verify a Proof of Work solution.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
challenge: 8-byte server-provided challenge
|
|
27
|
-
solution: Concatenated 8-byte nonces (8 * work bytes)
|
|
28
|
-
work: Number of work units expected
|
|
29
|
-
|
|
30
|
-
Raises:
|
|
31
|
-
ValueError: If the solution is invalid
|
|
32
|
-
"""
|
|
33
|
-
if len(challenge) != 8:
|
|
34
|
-
raise ValueError("Invalid challenge length")
|
|
35
|
-
|
|
36
|
-
if len(solution) != 8 * work:
|
|
37
|
-
raise ValueError("Invalid solution length")
|
|
38
|
-
|
|
39
|
-
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
|
40
|
-
for i in range(work):
|
|
41
|
-
nonce = solution[i * 8 : (i + 1) * 8]
|
|
42
|
-
# Require first byte of PBKDF2-SHA512 to be zero
|
|
43
|
-
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
|
44
|
-
if result[0] or result[1] & 0x07:
|
|
45
|
-
raise ValueError("Invalid PoW solution")
|
paskia/util/querysafe.py
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
|
|
3
|
-
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def assert_safe(value: str, *, field: str = "value") -> None:
|
|
7
|
-
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
|
8
|
-
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
__all__ = ["assert_safe"]
|
paskia/util/sessionutil.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""Utility functions for session validation and checking."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
|
|
5
|
-
from paskia.authsession import EXPIRES
|
|
6
|
-
from paskia.db import SessionContext
|
|
7
|
-
from paskia.util.timeutil import parse_duration
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
|
11
|
-
"""Check if a session satisfies the max_age requirement.
|
|
12
|
-
|
|
13
|
-
Uses the credential's last_used timestamp to determine authentication age,
|
|
14
|
-
since session renewal can happen without re-authentication.
|
|
15
|
-
|
|
16
|
-
Args:
|
|
17
|
-
ctx: The session context containing session and credential info
|
|
18
|
-
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
|
19
|
-
|
|
20
|
-
Returns:
|
|
21
|
-
True if authentication is recent enough or max_age is None, False if too old
|
|
22
|
-
|
|
23
|
-
Raises:
|
|
24
|
-
ValueError: If max_age format is invalid
|
|
25
|
-
"""
|
|
26
|
-
if not max_age:
|
|
27
|
-
return True
|
|
28
|
-
|
|
29
|
-
max_age_delta = parse_duration(max_age)
|
|
30
|
-
|
|
31
|
-
# Use credential's last_used time if available, fall back to session renewed time
|
|
32
|
-
if ctx.credential and ctx.credential.last_used:
|
|
33
|
-
auth_time = ctx.credential.last_used
|
|
34
|
-
else:
|
|
35
|
-
auth_time = ctx.session.expiry - EXPIRES
|
|
36
|
-
|
|
37
|
-
time_since_auth = datetime.now(timezone.utc) - auth_time
|
|
38
|
-
return time_since_auth <= max_age_delta
|
paskia/util/startupbox.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
"""Startup configuration box formatting utilities."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from sys import stderr
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
from paskia._version import __version__
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from paskia.config import PaskiaConfig
|
|
11
|
-
|
|
12
|
-
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def line(text: str = "") -> str:
|
|
16
|
-
"""Format a line inside the box with proper padding, truncating if needed."""
|
|
17
|
-
if len(text) > BOX_WIDTH:
|
|
18
|
-
text = text[: BOX_WIDTH - 1] + "…"
|
|
19
|
-
return f"┃ {text:<{BOX_WIDTH}} ┃\n"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def top() -> str:
|
|
23
|
-
return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def bottom() -> str:
|
|
27
|
-
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def print_startup_config(config: "PaskiaConfig") -> None:
|
|
31
|
-
"""Print server configuration on startup."""
|
|
32
|
-
lines = [top()]
|
|
33
|
-
lines.append(line(" ▄▄▄▄▄"))
|
|
34
|
-
lines.append(line("█ █ Paskia " + __version__))
|
|
35
|
-
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
|
|
36
|
-
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
|
|
37
|
-
lines.append(line(" ▀▀▀▀▀"))
|
|
38
|
-
|
|
39
|
-
# Format auth host section
|
|
40
|
-
if config.auth_host:
|
|
41
|
-
lines.append(line(f"Auth Host: {config.auth_host}"))
|
|
42
|
-
|
|
43
|
-
# Show frontend URL if in dev mode
|
|
44
|
-
devmode = os.environ.get("PASKIA_DEVMODE")
|
|
45
|
-
if devmode:
|
|
46
|
-
lines.append(line(f"Dev Frontend: {devmode}"))
|
|
47
|
-
|
|
48
|
-
# Format listen address with scheme
|
|
49
|
-
if config.uds:
|
|
50
|
-
listen = f"unix:{config.uds}"
|
|
51
|
-
elif config.host:
|
|
52
|
-
listen = f"http://{config.host}:{config.port}"
|
|
53
|
-
else:
|
|
54
|
-
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
|
|
55
|
-
lines.append(line(f"Backend: {listen}"))
|
|
56
|
-
|
|
57
|
-
# Relying Party line (omit name if same as id)
|
|
58
|
-
rp_id = config.rp_id
|
|
59
|
-
rp_name = config.rp_name
|
|
60
|
-
if rp_name and rp_name != rp_id:
|
|
61
|
-
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
|
62
|
-
else:
|
|
63
|
-
lines.append(line(f"Relying Party: {rp_id}"))
|
|
64
|
-
|
|
65
|
-
# Format origins section
|
|
66
|
-
allowed = config.origins
|
|
67
|
-
if allowed:
|
|
68
|
-
lines.append(line("Permitted Origins:"))
|
|
69
|
-
for origin in sorted(allowed):
|
|
70
|
-
lines.append(line(f" - {origin}"))
|
|
71
|
-
else:
|
|
72
|
-
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
|
73
|
-
|
|
74
|
-
lines.append(bottom())
|
|
75
|
-
stderr.write("".join(lines))
|
paskia/util/timeutil.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Utility functions for parsing time durations."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from datetime import timedelta
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def parse_duration(duration_str: str) -> timedelta:
|
|
8
|
-
"""Parse a duration string into a timedelta.
|
|
9
|
-
|
|
10
|
-
Supports units: s, m, min, h, d
|
|
11
|
-
Examples: "30s", "5m", "5min", "2h", "1d"
|
|
12
|
-
|
|
13
|
-
Args:
|
|
14
|
-
duration_str: A string like "30s", "5m", "2h"
|
|
15
|
-
|
|
16
|
-
Returns:
|
|
17
|
-
A timedelta object
|
|
18
|
-
|
|
19
|
-
Raises:
|
|
20
|
-
ValueError: If the format is invalid
|
|
21
|
-
"""
|
|
22
|
-
duration_str = duration_str.strip().lower()
|
|
23
|
-
|
|
24
|
-
# Pattern matches: number + unit
|
|
25
|
-
# Units: s (seconds), m/min (minutes), h (hours), d (days)
|
|
26
|
-
pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
|
|
27
|
-
match = re.match(pattern, duration_str)
|
|
28
|
-
|
|
29
|
-
if not match:
|
|
30
|
-
raise ValueError(
|
|
31
|
-
f"Invalid duration format: '{duration_str}'. "
|
|
32
|
-
"Expected format like '30s', '5m', '5min', '2h', or '1d'"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
value = float(match.group(1))
|
|
36
|
-
unit = match.group(2)
|
|
37
|
-
|
|
38
|
-
if unit == "s":
|
|
39
|
-
return timedelta(seconds=value)
|
|
40
|
-
elif unit in ("m", "min"):
|
|
41
|
-
return timedelta(minutes=value)
|
|
42
|
-
elif unit == "h":
|
|
43
|
-
return timedelta(hours=value)
|
|
44
|
-
elif unit == "d":
|
|
45
|
-
return timedelta(days=value)
|
|
46
|
-
else:
|
|
47
|
-
raise ValueError(f"Unsupported time unit: {unit}")
|
paskia/util/useragent.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import user_agents
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def compact_user_agent(ua: str | None) -> str:
|
|
5
|
-
if not ua:
|
|
6
|
-
return "-"
|
|
7
|
-
u = user_agents.parse(ua)
|
|
8
|
-
ver = u.browser.version_string.split(".")[0]
|
|
9
|
-
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
|
|
10
|
-
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
|
paskia/util/userinfo.py
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
"""User information formatting and retrieval logic."""
|
|
2
|
-
|
|
3
|
-
from datetime import timezone
|
|
4
|
-
|
|
5
|
-
from paskia import aaguid, db
|
|
6
|
-
from paskia.authsession import EXPIRES
|
|
7
|
-
from paskia.util import hostutil, permutil, useragent
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _format_datetime(dt):
|
|
11
|
-
"""Format a datetime object to ISO 8601 string with UTC timezone."""
|
|
12
|
-
if dt is None:
|
|
13
|
-
return None
|
|
14
|
-
if dt.tzinfo:
|
|
15
|
-
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
16
|
-
else:
|
|
17
|
-
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
async def format_user_info(
|
|
21
|
-
*,
|
|
22
|
-
user_uuid,
|
|
23
|
-
auth: str,
|
|
24
|
-
session_record,
|
|
25
|
-
request_host: str | None,
|
|
26
|
-
) -> dict:
|
|
27
|
-
"""Format complete user information for authenticated users.
|
|
28
|
-
|
|
29
|
-
Args:
|
|
30
|
-
user_uuid: UUID of the user to fetch information for
|
|
31
|
-
auth: Authentication token
|
|
32
|
-
session_record: Current session record
|
|
33
|
-
request_host: Host header from the request
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
Dictionary containing formatted user information including:
|
|
37
|
-
- User details
|
|
38
|
-
- Organization and role information
|
|
39
|
-
- Credentials list
|
|
40
|
-
- Sessions list
|
|
41
|
-
- Permissions
|
|
42
|
-
"""
|
|
43
|
-
u = db.get_user_by_uuid(user_uuid)
|
|
44
|
-
ctx = await permutil.session_context(auth, request_host)
|
|
45
|
-
|
|
46
|
-
# Fetch and format credentials
|
|
47
|
-
user_credentials = db.get_credentials_by_user_uuid(user_uuid)
|
|
48
|
-
credentials: list[dict] = []
|
|
49
|
-
user_aaguids: set[str] = set()
|
|
50
|
-
|
|
51
|
-
for c in user_credentials:
|
|
52
|
-
aaguid_str = str(c.aaguid)
|
|
53
|
-
user_aaguids.add(aaguid_str)
|
|
54
|
-
credentials.append(
|
|
55
|
-
{
|
|
56
|
-
"credential_uuid": str(c.uuid),
|
|
57
|
-
"aaguid": aaguid_str,
|
|
58
|
-
"created_at": _format_datetime(c.created_at),
|
|
59
|
-
"last_used": _format_datetime(c.last_used),
|
|
60
|
-
"last_verified": _format_datetime(c.last_verified),
|
|
61
|
-
"sign_count": c.sign_count,
|
|
62
|
-
"is_current_session": session_record.credential_uuid == c.uuid,
|
|
63
|
-
}
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
credentials.sort(key=lambda cred: cred["created_at"])
|
|
67
|
-
aaguid_info = aaguid.filter(user_aaguids)
|
|
68
|
-
|
|
69
|
-
# Format role and org information
|
|
70
|
-
role_info = None
|
|
71
|
-
org_info = None
|
|
72
|
-
effective_permissions: list[str] = []
|
|
73
|
-
|
|
74
|
-
if ctx:
|
|
75
|
-
role_info = {
|
|
76
|
-
"uuid": str(ctx.role.uuid),
|
|
77
|
-
"display_name": ctx.role.display_name,
|
|
78
|
-
"permissions": ctx.role.permissions,
|
|
79
|
-
}
|
|
80
|
-
org_info = {
|
|
81
|
-
"uuid": str(ctx.org.uuid),
|
|
82
|
-
"display_name": ctx.org.display_name,
|
|
83
|
-
"permissions": ctx.org.permissions,
|
|
84
|
-
}
|
|
85
|
-
effective_permissions = [p.scope for p in (ctx.permissions or [])]
|
|
86
|
-
|
|
87
|
-
# Format sessions
|
|
88
|
-
normalized_request_host = hostutil.normalize_host(request_host)
|
|
89
|
-
session_records = db.list_sessions_for_user(user_uuid)
|
|
90
|
-
current_session_key = auth
|
|
91
|
-
sessions_payload: list[dict] = []
|
|
92
|
-
|
|
93
|
-
for entry in session_records:
|
|
94
|
-
sessions_payload.append(
|
|
95
|
-
{
|
|
96
|
-
"id": entry.key,
|
|
97
|
-
"credential_uuid": str(entry.credential_uuid),
|
|
98
|
-
"host": entry.host,
|
|
99
|
-
"ip": entry.ip,
|
|
100
|
-
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
|
101
|
-
"last_renewed": _format_datetime(entry.expiry - EXPIRES),
|
|
102
|
-
"is_current": entry.key == current_session_key,
|
|
103
|
-
"is_current_host": bool(
|
|
104
|
-
normalized_request_host
|
|
105
|
-
and entry.host
|
|
106
|
-
and entry.host == normalized_request_host
|
|
107
|
-
),
|
|
108
|
-
}
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
"authenticated": True,
|
|
113
|
-
"user": {
|
|
114
|
-
"user_uuid": str(u.uuid),
|
|
115
|
-
"user_name": u.display_name,
|
|
116
|
-
"created_at": _format_datetime(u.created_at),
|
|
117
|
-
"last_seen": _format_datetime(u.last_seen),
|
|
118
|
-
"visits": u.visits,
|
|
119
|
-
},
|
|
120
|
-
"org": org_info,
|
|
121
|
-
"role": role_info,
|
|
122
|
-
"permissions": effective_permissions,
|
|
123
|
-
"credentials": credentials,
|
|
124
|
-
"aaguid_info": aaguid_info,
|
|
125
|
-
"sessions": sessions_payload,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
async def format_reset_user_info(user_uuid, reset_token) -> dict:
|
|
130
|
-
"""Format minimal user information for reset token requests.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
user_uuid: UUID of the user
|
|
134
|
-
reset_token: Reset token record
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
Dictionary with minimal user info for password reset flow
|
|
138
|
-
"""
|
|
139
|
-
u = db.get_user_by_uuid(user_uuid)
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
"authenticated": False,
|
|
143
|
-
"session_type": reset_token.token_type,
|
|
144
|
-
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
|
145
|
-
}
|
paskia/util/wordlist.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
|
2
|
-
words: list = """
|
|
3
|
-
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
|
4
|
-
aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal
|
|
5
|
-
ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom
|
|
6
|
-
attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become
|
|
7
|
-
beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue
|
|
8
|
-
board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget
|
|
9
|
-
build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement
|
|
10
|
-
census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee
|
|
11
|
-
coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve
|
|
12
|
-
custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial
|
|
13
|
-
depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain
|
|
14
|
-
donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east
|
|
15
|
-
echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine
|
|
16
|
-
enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact
|
|
17
|
-
excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch
|
|
18
|
-
fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow
|
|
19
|
-
food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas
|
|
20
|
-
gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern
|
|
21
|
-
gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk
|
|
22
|
-
hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse
|
|
23
|
-
host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income
|
|
24
|
-
index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james
|
|
25
|
-
jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind
|
|
26
|
-
kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal
|
|
27
|
-
lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load
|
|
28
|
-
local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male
|
|
29
|
-
mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind
|
|
30
|
-
mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name
|
|
31
|
-
napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal
|
|
32
|
-
nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit
|
|
33
|
-
once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen
|
|
34
|
-
oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit
|
|
35
|
-
pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond
|
|
36
|
-
pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen
|
|
37
|
-
quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform
|
|
38
|
-
region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot
|
|
39
|
-
ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage
|
|
40
|
-
sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle
|
|
41
|
-
seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski
|
|
42
|
-
skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south
|
|
43
|
-
space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden
|
|
44
|
-
suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk
|
|
45
|
-
tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt
|
|
46
|
-
time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade
|
|
47
|
-
tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock
|
|
48
|
-
until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein
|
|
49
|
-
velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage
|
|
50
|
-
wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish
|
|
51
|
-
wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane
|
|
52
|
-
zara zebra zen zero zippo zone zoo zorro zulu
|
|
53
|
-
""".split()
|
|
54
|
-
assert len(words) == 1024 # Exactly 10 bits of entropy per word
|
|
File without changes
|
|
File without changes
|