|
19 | 19 | PublicKeyCredentialUserEntity, |
20 | 20 | ) |
21 | 21 |
|
| 22 | +from django_mfa.atomic import update_data |
22 | 23 | from django_mfa.conf import settings as mfa_settings |
23 | 24 | from django_mfa.handles import user_handle_for |
24 | 25 | from django_mfa.models import Authenticator |
@@ -251,30 +252,48 @@ def complete_verify(self, request, user, data): |
251 | 252 | # still propagate unchanged, since that is a distinct, |
252 | 253 | # deliberate "reject this" signal, not a missing-row race. |
253 | 254 | return False |
254 | | - stored = auth.data.get("sign_count", 0) |
255 | 255 | # The return value of authenticate_complete() carries no counter at |
256 | 256 | # all in fido2 2.2.1, so the new counter has to be recovered |
257 | 257 | # independently by re-parsing the same response the client sent. |
258 | 258 | # Confirmed empirically (task-14-report.md) that this equals the |
259 | 259 | # authenticator's own idea of its counter. |
260 | 260 | new_count = AuthenticationResponse.from_dict( |
261 | 261 | 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.") |
276 | 262 |
|
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 |
279 | 298 | auth.record_usage() |
280 | 299 | return True |
0 commit comments