quart-security 2.0.0__tar.gz → 2.0.2__tar.gz

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 (33) hide show
  1. {quart_security-2.0.0 → quart_security-2.0.2}/PKG-INFO +9 -2
  2. {quart_security-2.0.0 → quart_security-2.0.2}/README.md +7 -0
  3. {quart_security-2.0.0 → quart_security-2.0.2}/pyproject.toml +2 -3
  4. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/breach.py +2 -2
  5. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/core.py +11 -0
  6. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/totp.py +49 -0
  7. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/views.py +14 -4
  8. {quart_security-2.0.0 → quart_security-2.0.2}/LICENSE +0 -0
  9. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/__init__.py +0 -0
  10. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/datastore.py +0 -0
  11. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/decorators.py +0 -0
  12. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/email.py +0 -0
  13. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/forms.py +0 -0
  14. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/models.py +0 -0
  15. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/password.py +0 -0
  16. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/proxies.py +0 -0
  17. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/py.typed +0 -0
  18. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/signals.py +0 -0
  19. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/state.py +0 -0
  20. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/_macros.html +0 -0
  21. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/change_password.html +0 -0
  22. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/login_user.html +0 -0
  23. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/mf_recovery.html +0 -0
  24. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  25. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/register_user.html +0 -0
  26. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_select.html +0 -0
  27. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_setup.html +0 -0
  28. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  29. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_register.html +0 -0
  30. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_signin.html +0 -0
  31. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_verify.html +0 -0
  32. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/utils.py +0 -0
  33. {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/webauthn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quart-security
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Native async auth for Quart
5
5
  Author: quart-security maintainers
6
6
  Requires-Python: >=3.11
@@ -19,7 +19,7 @@ Requires-Dist: aiosmtplib>=5.1.2
19
19
  Requires-Dist: blinker>=1.7
20
20
  Requires-Dist: email-validator>=2.0
21
21
  Requires-Dist: argon2-cffi>=23.1
22
- Requires-Dist: httpx>=0.27
22
+ Requires-Dist: httpx2>=2.16
23
23
  Requires-Dist: pytest>=8.0 ; extra == "dev"
24
24
  Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
25
25
  Requires-Dist: ruff>=0.6 ; extra == "dev"
@@ -358,3 +358,10 @@ Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and p
358
358
  minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
359
359
  lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
360
360
 
361
+
362
+ ## TOTP encryption and key rotation
363
+
364
+ Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
365
+
366
+ By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
367
+
@@ -327,3 +327,10 @@ or SQLAlchemy model fields.
327
327
  Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched
328
328
  minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
329
329
  lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
330
+
331
+
332
+ ## TOTP encryption and key rotation
333
+
334
+ Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
335
+
336
+ By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "Native async auth for Quart"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  "blinker>=1.7",
27
27
  "email-validator>=2.0",
28
28
  "argon2-cffi>=23.1",
29
- "httpx>=0.27",
29
+ "httpx2>=2.16",
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
@@ -49,7 +49,6 @@ dev = [
49
49
  "aiosqlite>=0.20",
50
50
  "pytest>=8.0",
51
51
  "pytest-asyncio>=0.23",
52
- "respx>=0.23.1",
53
52
  "ruff>=0.6",
54
53
  ]
55
54
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import logging
7
7
 
8
- import httpx
8
+ import httpx2
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -32,7 +32,7 @@ async def password_is_breached(password: str, *, count_min: int = 1) -> bool | N
32
32
  prefix, suffix = sha1[:5], sha1[5:]
33
33
 
34
34
  try:
35
- async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
35
+ async with httpx2.AsyncClient(timeout=_TIMEOUT) as client:
36
36
  response = await client.get(
37
37
  _HIBP_URL.format(prefix=prefix),
38
38
  headers={"Add-Padding": "true"},
@@ -14,6 +14,7 @@ from .password import init_password_context
14
14
  from .proxies import AnonymousUser, current_user
15
15
  from .signals import user_authenticated, user_logged_out
16
16
  from .state import SQLAlchemyStateStore
17
+ from .totp import encrypt_totp_secret, init_totp_encryption
17
18
  from .utils import maybe_await, naive_utcnow, url_for_security
18
19
 
19
20
 
@@ -119,6 +120,7 @@ class Security:
119
120
  app.config.get("SESSION_COOKIE_SAMESITE") or "Lax"
120
121
  )
121
122
  init_password_context(app)
123
+ init_totp_encryption(app)
122
124
 
123
125
  from .views import security_bp
124
126
 
@@ -228,6 +230,11 @@ class Security:
228
230
  session.clear()
229
231
  g._current_user = AnonymousUser()
230
232
  return
233
+ if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
234
+ "fernet$"
235
+ ):
236
+ user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
237
+ await maybe_await(self.datastore.commit())
231
238
  g._current_user = user
232
239
 
233
240
  async def login_user(self, user, fresh=True):
@@ -248,6 +255,10 @@ class Security:
248
255
  user.current_login_ip = request.remote_addr
249
256
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
250
257
 
258
+ if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
259
+ "fernet$"
260
+ ):
261
+ user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
251
262
  await maybe_await(self.datastore.commit())
252
263
  token = await self.state_store.put(
253
264
  {"user_id": user_id},
@@ -9,6 +9,53 @@ import io
9
9
  import secrets
10
10
  import time
11
11
 
12
+ _TOTP_CIPHER_PREFIX = "fernet$"
13
+
14
+
15
+ def derive_totp_encryption_key(secret_key):
16
+ if isinstance(secret_key, str):
17
+ secret_key = secret_key.encode()
18
+ if not secret_key:
19
+ raise RuntimeError("TOTP encryption requires SECRET_KEY")
20
+ return base64.urlsafe_b64encode(
21
+ hmac.digest(secret_key, b"quart-security/totp-encryption/v1", "sha256")
22
+ )
23
+
24
+
25
+ def init_totp_encryption(app):
26
+ from cryptography.fernet import Fernet, MultiFernet
27
+
28
+ keys = app.config.get("SECURITY_TOTP_ENCRYPTION_KEYS")
29
+ if keys is None:
30
+ keys = [derive_totp_encryption_key(app.secret_key)]
31
+ if not isinstance(keys, (list, tuple)) or not keys:
32
+ raise ValueError("SECURITY_TOTP_ENCRYPTION_KEYS must be a nonempty key list")
33
+ app.extensions["quart_security_totp"] = MultiFernet([Fernet(key) for key in keys])
34
+
35
+
36
+ def _totp_cipher(app=None):
37
+ from quart import current_app
38
+
39
+ return (app or current_app).extensions["quart_security_totp"]
40
+
41
+
42
+ def encrypt_totp_secret(secret, *, app=None):
43
+ cipher = _totp_cipher(app)
44
+ if secret.startswith(_TOTP_CIPHER_PREFIX):
45
+ token = cipher.rotate(secret[len(_TOTP_CIPHER_PREFIX) :].encode())
46
+ else:
47
+ token = cipher.encrypt(secret.encode())
48
+ return _TOTP_CIPHER_PREFIX + token.decode()
49
+
50
+
51
+ def decrypt_totp_secret(secret, *, app=None):
52
+ # Legacy seeds remain readable until the host encrypts existing records.
53
+ if not secret.startswith(_TOTP_CIPHER_PREFIX):
54
+ return secret
55
+ return (
56
+ _totp_cipher(app).decrypt(secret[len(_TOTP_CIPHER_PREFIX) :].encode()).decode()
57
+ )
58
+
12
59
 
13
60
  def _require_pyotp():
14
61
  try:
@@ -24,6 +71,7 @@ def generate_totp_secret() -> str:
24
71
 
25
72
 
26
73
  def get_totp_uri(secret: str, email: str, issuer: str) -> str:
74
+ secret = decrypt_totp_secret(secret)
27
75
  pyotp = _require_pyotp()
28
76
  return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)
29
77
 
@@ -46,6 +94,7 @@ def verify_totp(secret: str, token: str) -> bool:
46
94
 
47
95
 
48
96
  def matching_totp_step(secret: str, token: str) -> int | None:
97
+ secret = decrypt_totp_secret(secret)
49
98
  pyotp = _require_pyotp()
50
99
  totp = pyotp.TOTP(secret)
51
100
  step = int(time.time()) // totp.interval
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import datetime
6
7
  import hashlib
7
8
  import hmac
@@ -165,6 +166,10 @@ def _webauthn_rp_name() -> str:
165
166
 
166
167
  async def _set_wan_state(key: str, **payload):
167
168
  await _pop_wan_state(key)
169
+ if key == "tf_setup_state":
170
+ from .totp import encrypt_totp_secret
171
+
172
+ payload["secret"] = encrypt_totp_secret(payload["secret"])
168
173
  session[key] = await _security.state_store.put(payload, ttl=300)
169
174
 
170
175
 
@@ -174,10 +179,11 @@ async def _pop_wan_state(key: str) -> dict | None:
174
179
 
175
180
 
176
181
  async def _verify_totp_once(secret, token):
177
- from .totp import matching_totp_step
182
+ from .totp import decrypt_totp_secret, matching_totp_step
178
183
 
179
184
  if not secret or not token:
180
185
  return False
186
+ secret = decrypt_totp_secret(secret)
181
187
  step = matching_totp_step(secret, token)
182
188
  if step is None:
183
189
  return False
@@ -592,7 +598,9 @@ async def two_factor_setup():
592
598
  and token
593
599
  and await _verify_totp_once(pending_secret, token)
594
600
  ):
595
- current_user.tf_totp_secret = pending_secret
601
+ from .totp import encrypt_totp_secret
602
+
603
+ current_user.tf_totp_secret = encrypt_totp_secret(pending_secret)
596
604
  current_user.tf_primary_method = "authenticator"
597
605
 
598
606
  raw_codes = []
@@ -642,11 +650,13 @@ async def two_factor_setup():
642
650
  "tf_setup_state", secret=pending_secret, user_id=current_user.get_id()
643
651
  )
644
652
  else:
645
- pending_secret = pending["secret"]
653
+ from .totp import decrypt_totp_secret
654
+
655
+ pending_secret = decrypt_totp_secret(pending["secret"])
646
656
  authr_key = pending_secret
647
657
  issuer = current_app.config.get("SECURITY_TOTP_ISSUER", "Quart")
648
658
  uri = get_totp_uri(pending_secret, current_user.email, issuer)
649
- authr_qrcode = generate_qr_code(uri)
659
+ authr_qrcode = await asyncio.to_thread(generate_qr_code, uri)
650
660
 
651
661
  return await render_template(
652
662
  "security/two_factor_setup.html",
File without changes