Skip to content

Commit 7880f66

Browse files
authored
Merge pull request #89 from MicroPyramid/dev
Dev
2 parents b53f2c7 + 7e559e6 commit 7880f66

24 files changed

Lines changed: 1053 additions & 116 deletions

.github/workflows/ci.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ jobs:
2424
django: ["4.2", "5.2"]
2525

2626
steps:
27-
- uses: actions/checkout@v4
27+
- uses: actions/checkout@v7
2828

2929
- name: Install uv
30-
uses: astral-sh/setup-uv@v5
30+
uses: astral-sh/setup-uv@v10.0.0
3131
with:
3232
enable-cache: true
3333

@@ -54,8 +54,8 @@ jobs:
5454
name: Coverage
5555
runs-on: ubuntu-latest
5656
steps:
57-
- uses: actions/checkout@v4
58-
- uses: astral-sh/setup-uv@v5
57+
- uses: actions/checkout@v7
58+
- uses: astral-sh/setup-uv@v10.0.0
5959
with:
6060
enable-cache: true
6161
- run: uv run coverage run --source=django_mfa test_runner.py
@@ -65,8 +65,8 @@ jobs:
6565
name: Docs
6666
runs-on: ubuntu-latest
6767
steps:
68-
- uses: actions/checkout@v4
69-
- uses: astral-sh/setup-uv@v5
68+
- uses: actions/checkout@v7
69+
- uses: astral-sh/setup-uv@v10.0.0
7070
with:
7171
enable-cache: true
7272

@@ -149,8 +149,8 @@ jobs:
149149
name: Build and install-smoke
150150
runs-on: ubuntu-latest
151151
steps:
152-
- uses: actions/checkout@v4
153-
- uses: astral-sh/setup-uv@v5
152+
- uses: actions/checkout@v7
153+
- uses: astral-sh/setup-uv@v10.0.0
154154
with:
155155
enable-cache: true
156156

@@ -180,7 +180,7 @@ jobs:
180180
working-directory: /tmp
181181
run: /tmp/smoke/bin/python "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py"
182182

183-
- uses: actions/upload-artifact@v4
183+
- uses: actions/upload-artifact@v7
184184
with:
185185
name: distributions
186186
path: dist/

.github/workflows/publish.yml

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,13 @@ jobs:
6767
- python-version: "3.13"
6868
django: "5.2"
6969
steps:
70-
- uses: actions/checkout@v4
70+
- uses: actions/checkout@v7
7171

7272
- name: Install uv
73-
uses: astral-sh/setup-uv@v5
73+
uses: astral-sh/setup-uv@v10.0.0
7474
with:
75-
enable-cache: true
75+
# No cache anywhere in this workflow -- see the build job below.
76+
enable-cache: false
7677

7778
- name: Run the test suite
7879
run: >
@@ -86,12 +87,28 @@ jobs:
8687
needs: test
8788
runs-on: ubuntu-latest
8889
steps:
89-
- uses: actions/checkout@v4
90+
- uses: actions/checkout@v7
9091

9192
- name: Install uv
92-
uses: astral-sh/setup-uv@v5
93+
uses: astral-sh/setup-uv@v10.0.0
9394
with:
94-
enable-cache: true
95+
# Deliberately off, and not just for speed reasons.
96+
#
97+
# The GitHub Actions cache is writable from less-trusted contexts
98+
# (a pull_request run can poison an entry a later run restores), so
99+
# a cache is an input to whatever this job builds -- and what this
100+
# job builds is what gets published to PyPI under the project's
101+
# name. setup-uv v10 made exactly this its headline change,
102+
# disabling caching for release, tag-push, pull_request_target and
103+
# workflow_run events when `enable-cache: auto`.
104+
#
105+
# `auto` is not enough here: this workflow's normal entry point is
106+
# workflow_dispatch on a tag, which is not in that list. And an
107+
# explicit `true` skips the check entirely -- setup-uv reads it as
108+
# `return enableCacheInput === "true"`, before any event test. Only
109+
# an explicit `false` is actually off. ci.yml still caches; it
110+
# publishes nothing.
111+
enable-cache: false
95112

96113
- name: Refuse to publish if the tag and the packaged version disagree
97114
# Nothing else connects the two. hatchling reads the version from
@@ -141,7 +158,7 @@ jobs:
141158
run: /tmp/smoke/bin/python "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py"
142159

143160
- name: Upload the distributions for the publish job
144-
uses: actions/upload-artifact@v4
161+
uses: actions/upload-artifact@v7
145162
with:
146163
name: distributions
147164
path: dist/
@@ -161,7 +178,7 @@ jobs:
161178
- name: Download the distributions built above
162179
# Deliberately not a rebuild: what gets uploaded is byte-for-byte the
163180
# artifact the install-smoke actually exercised.
164-
uses: actions/download-artifact@v4
181+
uses: actions/download-artifact@v8
165182
with:
166183
name: distributions
167184
path: dist/

.github/workflows/tag-release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ jobs:
4141
name: Tag and release if the version changed
4242
runs-on: ubuntu-latest
4343
steps:
44-
- uses: actions/checkout@v4
44+
- uses: actions/checkout@v7
4545
with:
4646
# Existing tags are how this decides whether the version was already
4747
# released, and a shallow clone fetches none of them -- it would
4848
# re-release every version, every time.
4949
fetch-depth: 0
5050

5151
- name: Install uv
52-
uses: astral-sh/setup-uv@v5
52+
uses: astral-sh/setup-uv@v10.0.0
5353
with:
5454
enable-cache: true
5555

django_mfa/adapters/recovery_codes.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from django.contrib.auth.hashers import check_password, make_password
66

7+
from django_mfa.atomic import update_data
78
from django_mfa.models import Authenticator
89
from django_mfa.registry import Adapter
910
from django_mfa.utils import strings_equal
@@ -52,17 +53,29 @@ def complete_verify(self, request, user, data):
5253
if auth is None:
5354
return False
5455
submitted = data.get("code", "")
55-
used = set(auth.data.get("used", []))
56-
plaintext = auth.data.get("migrated_plaintext", False)
5756

58-
for index, stored in enumerate(auth.data.get("codes", [])):
59-
if index in used:
60-
continue
61-
matched = (strings_equal(stored, submitted) if plaintext
62-
else check_password(submitted, stored))
63-
if matched:
64-
auth.data["used"] = sorted(used | {index})
65-
auth.save(update_fields=["data"])
66-
auth.record_usage()
67-
return True
68-
return False
57+
def spend(current):
58+
"""Match and mark one code, against the blob as committed.
59+
60+
Deliberately does the matching *inside* the compare-and-set
61+
rather than before it: the `used` set this consults has to be the
62+
one the write is conditioned on, or a code another request spent
63+
microseconds ago still reads as unspent here. Returning None
64+
declines the write, which update_data() reports as a failed
65+
verification.
66+
"""
67+
used = set(current.get("used", []))
68+
plaintext = current.get("migrated_plaintext", False)
69+
for index, stored in enumerate(current.get("codes", [])):
70+
if index in used:
71+
continue
72+
matched = (strings_equal(stored, submitted) if plaintext
73+
else check_password(submitted, stored))
74+
if matched:
75+
return {**current, "used": sorted(used | {index})}
76+
return None
77+
78+
if not update_data(auth, spend):
79+
return False
80+
auth.record_usage()
81+
return True

django_mfa/adapters/totp.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import base64
2-
import codecs
3-
import random
42
import re
3+
import secrets
54

65
from django_mfa import totp as totp_mod
6+
from django_mfa.atomic import update_data
77
from django_mfa.conf import settings as mfa_settings
88
from django_mfa.crypto import decrypt, encrypt
99
from django_mfa.models import Authenticator
@@ -18,10 +18,26 @@
1818
TOTP_VALID_WINDOW = 1
1919

2020

21+
#: RFC 4226 R6: "The algorithm MUST use a strong shared secret. The length of
22+
#: the shared secret MUST be at least 128 bits. This document RECOMMENDs a
23+
#: shared secret length of 160 bits." 20 bytes also base32-encodes to exactly
24+
#: 32 characters with no '=' padding, which matters: OTP.byte_secret() re-pads
25+
#: self.secret in place and provisioning_uri() embeds the secret verbatim in
26+
#: the otpauth:// query string, so a padded secret would put '=' mid-URI.
27+
SECRET_BYTES = 20
28+
29+
2130
def generate_secret():
22-
raw = codecs.decode(codecs.encode(f"{random.getrandbits(80):020x}"),
23-
"hex_codec")
24-
return base64.b32encode(raw).decode("utf-8")
31+
"""Return a fresh base32 TOTP shared secret.
32+
33+
Uses `secrets` (os.urandom) rather than the `random` module. This
34+
previously drew from random.getrandbits(80): the Mersenne Twister is not
35+
a CSPRNG -- its 19937-bit state is recoverable from 624 observed 32-bit
36+
outputs, after which every subsequently issued secret is predictable --
37+
and it is the process-global instance shared with all other application
38+
code. 80 bits was also below the RFC 4226 floor.
39+
"""
40+
return base64.b32encode(secrets.token_bytes(SECRET_BYTES)).decode("utf-8")
2541

2642

2743
class TOTPAdapter(Adapter):
@@ -51,8 +67,27 @@ def complete_verify(self, request, user, data):
5167
auth = self.get_instances(user).first()
5268
if auth is None:
5369
return False
54-
if totp_mod.TOTP(decrypt(auth.data["secret"])).verify(
55-
data.get("code", ""), valid_window=TOTP_VALID_WINDOW):
56-
auth.record_usage()
57-
return True
58-
return False
70+
counter = totp_mod.TOTP(decrypt(auth.data["secret"])).match(
71+
data.get("code", ""), valid_window=TOTP_VALID_WINDOW)
72+
if counter is None:
73+
return False
74+
# RFC 6238 section 5.2: an OTP must not be accepted twice. Rejecting
75+
# any counter at or below the last accepted one closes both the
76+
# straight replay of the same code and the replay of an *earlier*
77+
# still-in-window code after a later one has been used -- with
78+
# TOTP_VALID_WINDOW = 1 a code would otherwise stay replayable for
79+
# about 90 seconds, which is ample for a shoulder-surfed or
80+
# phished-then-forwarded code.
81+
#
82+
# The compare-and-set is what makes this hold under concurrency: two
83+
# simultaneous POSTs of the same code both match here, and without it
84+
# both would go on to succeed.
85+
def spend(data):
86+
if counter <= data.get("last_verified_counter", -1):
87+
return None
88+
return {**data, "last_verified_counter": counter}
89+
90+
if not update_data(auth, spend):
91+
return False
92+
auth.record_usage()
93+
return True

django_mfa/adapters/webauthn.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
PublicKeyCredentialUserEntity,
2020
)
2121

22+
from django_mfa.atomic import update_data
2223
from django_mfa.conf import settings as mfa_settings
2324
from django_mfa.handles import user_handle_for
2425
from django_mfa.models import Authenticator
@@ -251,30 +252,48 @@ def complete_verify(self, request, user, data):
251252
# still propagate unchanged, since that is a distinct,
252253
# deliberate "reject this" signal, not a missing-row race.
253254
return False
254-
stored = auth.data.get("sign_count", 0)
255255
# The return value of authenticate_complete() carries no counter at
256256
# all in fido2 2.2.1, so the new counter has to be recovered
257257
# independently by re-parsing the same response the client sent.
258258
# Confirmed empirically (task-14-report.md) that this equals the
259259
# authenticator's own idea of its counter.
260260
new_count = AuthenticationResponse.from_dict(
261261
credential).response.authenticator_data.counter
262-
# Clone detection: a signature counter that fails to advance
263-
# suggests the authenticator (or its key material) has been cloned
264-
# and a second device is replaying/racing assertions. But counters
265-
# are OPTIONAL in the WebAuthn spec -- many authenticators
266-
# (notably Apple/iCloud passkeys) never implement one and always
267-
# report 0. Treat "both stored and new are 0" as the legitimate
268-
# counter-less case and accept it unconditionally; for every other
269-
# case, the new counter must be strictly greater than the stored
270-
# one or this assertion is rejected as a possible clone. Do NOT
271-
# simplify this to a plain "new > stored" check -- that would lock
272-
# out every user of a counter-less authenticator, which today is a
273-
# very large share of them.
274-
if not (new_count == 0 and stored == 0) and new_count <= stored:
275-
raise ValueError("Authenticator sign count did not increase.")
276262

277-
auth.data["sign_count"] = new_count
278-
auth.save(update_fields=["data"])
263+
def advance(current):
264+
"""Clone-check against the committed counter, then advance it.
265+
266+
Runs inside the compare-and-set rather than before it, and for
267+
the same reason clone detection exists at all: two racing
268+
assertions from a cloned credential would otherwise both read the
269+
pre-advance counter, both clear the check below, and the loser's
270+
write would drag the stored counter *backwards* -- leaving the
271+
clone undetected and every subsequent replay looking fresh.
272+
"""
273+
stored = current.get("sign_count", 0)
274+
# Clone detection: a signature counter that fails to advance
275+
# suggests the authenticator (or its key material) has been
276+
# cloned and a second device is replaying/racing assertions. But
277+
# counters are OPTIONAL in the WebAuthn spec -- many
278+
# authenticators (notably Apple/iCloud passkeys) never implement
279+
# one and always report 0. Treat "both stored and new are 0" as
280+
# the legitimate counter-less case and accept it
281+
# unconditionally; for every other case, the new counter must be
282+
# strictly greater than the stored one or this assertion is
283+
# rejected as a possible clone. Do NOT simplify this to a plain
284+
# "new > stored" check -- that would lock out every user of a
285+
# counter-less authenticator, which today is a very large share
286+
# of them.
287+
#
288+
# Raising (rather than returning None to decline) is deliberate:
289+
# it propagates straight out of update_data() without retrying,
290+
# keeping "this is a clone" a distinct, loud signal from "this
291+
# assertion simply could not be honoured".
292+
if not (new_count == 0 and stored == 0) and new_count <= stored:
293+
raise ValueError("Authenticator sign count did not increase.")
294+
return {**current, "sign_count": new_count}
295+
296+
if not update_data(auth, advance):
297+
return False
279298
auth.record_usage()
280299
return True

django_mfa/admin.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,56 @@
11
from django.contrib import admin
2+
from django.contrib.auth import get_user_model
23

34
from .models import Authenticator
45

5-
admin.site.register(Authenticator)
6+
7+
@admin.register(Authenticator)
8+
class AuthenticatorAdmin(admin.ModelAdmin):
9+
"""Inspect-and-revoke only. Never exposes ``Authenticator.data``.
10+
11+
That blob holds the TOTP shared secret, the recovery-code hashes and the
12+
WebAuthn credential. Under the default ModelAdmin it was rendered as an
13+
editable form field, which made the admin a privilege-escalation surface:
14+
any staff account with ``view_authenticator`` could read another user's
15+
TOTP secret -- including a superuser's -- and generate valid codes for
16+
them, and one with ``change_authenticator`` could overwrite it with a
17+
secret of its own choosing. ``MFA_SECRET_ENCRYPTION_KEYS`` is not a
18+
mitigation: django_mfa.crypto *signs*, it does not encrypt, and the
19+
payload is plain base64 that recovers without any key (see
20+
docs/security.md).
21+
22+
``data`` is therefore absent from ``fields``, ``list_display`` and
23+
``get_search_fields()`` alike -- a searchable ``data`` would leak the
24+
secret a character at a time even while never rendering it -- and adding
25+
and editing are switched off outright, since nothing in this model is
26+
meaningfully hand-editable and every write to it is a way to weaken
27+
somebody's second factor.
28+
29+
Deleting is deliberately still allowed. Revoking a lost or stolen
30+
authenticator on behalf of a locked-out user is the one legitimate
31+
support operation here, and removing it would push operators towards
32+
editing the database by hand.
33+
"""
34+
35+
list_display = ("user", "type", "name", "created_at", "last_used_at")
36+
list_filter = ("type", "created_at", "last_used_at")
37+
date_hierarchy = "created_at"
38+
ordering = ("user", "type", "created_at")
39+
40+
#: Everything except `data`. Also the complete readonly set -- with
41+
#: has_change_permission() off these render as an inspectable, read-only
42+
#: detail page rather than a form.
43+
fields = ("user", "type", "name", "created_at", "last_used_at")
44+
readonly_fields = fields
45+
46+
def get_search_fields(self, request):
47+
# Derived from USERNAME_FIELD rather than hardcoding "username":
48+
# AUTH_USER_MODEL is swappable and a host project's user model need
49+
# not have that field at all.
50+
return ("name", f"user__{get_user_model().USERNAME_FIELD}")
51+
52+
def has_add_permission(self, request):
53+
return False
54+
55+
def has_change_permission(self, request, obj=None):
56+
return False

0 commit comments

Comments
 (0)