Coverage for src/django_otp_webauthn/models.py: 98%
113 statements
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:54 +0000
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:54 +0000
1import hashlib
3from django.contrib.auth import get_user_model
4from django.contrib.auth.models import AbstractUser
5from django.db import models
6from django.db.models import QuerySet
7from django.http import HttpRequest
8from django.utils.functional import cached_property
9from django.utils.module_loading import import_string
10from django.utils.translation import gettext_lazy as _
11from django_otp.models import Device, TimestampMixin
12from webauthn.helpers import parse_attestation_object
13from webauthn.helpers.structs import (
14 AttestationObject,
15 AuthenticatorTransport,
16 PublicKeyCredentialDescriptor,
17)
19from django_otp_webauthn.settings import app_settings
20from django_otp_webauthn.utils import get_credential_model_string
22User = get_user_model()
25def as_credential_descriptors(queryset: QuerySet["AbstractWebAuthnCredential"]) -> list[PublicKeyCredentialDescriptor]:
26 descriptors = []
27 for id, raw_transports in queryset.values_list("credential_id", "transports"):
28 transports = []
29 for t in raw_transports:
30 # Though the spec recommends we SHOULD NOT modify the transports
31 # in any way, py_webauthn requires we only pass values from the
32 # AuthenticatorTransport enum. We are therefore limited to only
33 # returning transports supported by AuthenticatorTransport.
35 # Relevant spec link:
36 # https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-transports
37 # > When registering a new credential, the Relying Party SHOULD
38 # > store the value returned from getTransports(). When creating
39 # > a PublicKeyCredentialDescriptor for that credential, the
40 # > Relying Party SHOULD retrieve that stored value and set it
41 # > as the value of the transports member.
42 if t in AuthenticatorTransport:
43 transports.append(AuthenticatorTransport(t))
44 descriptors.append(PublicKeyCredentialDescriptor(id=id, transports=transports))
45 return descriptors
48class WebAuthnAttestationManager(models.Manager):
49 def get_by_natural_key(self, credential_id_hash_bytes: bytes):
50 return self.get(credential__credential_id_sha256=credential_id_hash_bytes)
53class WebAuthnCredentialManager(models.Manager):
54 def get_by_natural_key(self, credential_id_hash_bytes: bytes):
55 return self.get(credential_id_sha256=credential_id_hash_bytes)
58class WebAuthnCredentialQuerySet(models.QuerySet):
59 def as_credential_descriptors(self):
60 return as_credential_descriptors(self)
63class AbstractWebAuthnAttestation(models.Model):
64 """Abstract model to store attestation for registered credentials for future reference.
66 This model is used to store the attestation object returned by the authenticator during registration.
68 See https://www.w3.org/TR/webauthn-3/#sctn-attestation for more information about attestation.
69 """
71 class Meta:
72 abstract = True
73 verbose_name = _("WebAuthn attestation")
74 verbose_name_plural = _("WebAuthn attestations")
76 objects = WebAuthnAttestationManager()
78 def __str__(self):
79 return f"{self.credential} (fmt={self.fmt})"
81 def natural_key(self):
82 return (self.credential.credential_id_sha256,)
84 class Format(models.TextChoices):
85 PACKED = "packed", "packed"
86 TPM = "tpm", "tpm"
87 ANDROID_KEY = "android-key", "android-key"
88 ANDROID_SAFETYNET = "android-safetynet", "android-safetynet"
89 FIDO_U2F = "fido-u2f", "fido-u2f"
90 NONE = "none", "none"
92 credential = models.OneToOneField(
93 to=get_credential_model_string(),
94 on_delete=models.CASCADE,
95 related_name="attestation",
96 verbose_name=_("credential"),
97 editable=False,
98 )
100 fmt = models.CharField(max_length=255, verbose_name=_("format"), editable=False, choices=Format.choices)
101 """The attestation format used by the authenticator. Extracted from the attestation object for convenience."""
103 data = models.BinaryField(verbose_name=_("data"), editable=False)
104 """The raw attestation data."""
106 client_data_json = models.BinaryField(verbose_name=_("client data JSON"), editable=False)
107 """The raw client data JSON, as originally sent by the client.
109 This is in binary form to preserve exactly what the client sent. This is
110 important because it needs to be byte-for-byte identical in order to verify
111 the signature."""
113 @cached_property
114 def attestation_object(self) -> AttestationObject:
115 """Return the parsed attestation object."""
116 return parse_attestation_object(self.data)
119class AbstractWebAuthnCredential(TimestampMixin, Device):
120 """
121 Abstract OTP device that validates against a user's WebAuthn credential.
123 See https://www.w3.org/TR/webauthn-3/ for more information about the FIDO 2 Web Authentication standard.
124 """
126 class Meta:
127 abstract = True
128 indexes = [
129 # Create an index on the credential_id_sha256 field to speed up lookups. Overridable if needed.
130 # For example, this index could be replaced by a hash index on databases that support it. Saves index space.
131 # Example: django.contrib.postgres.indexes.HashIndex(fields=["credential_id_sha256"], name="%(class)s_sha256_idx"),
132 models.Index(fields=["credential_id_sha256"], name="%(class)s_sha256_idx"),
133 ]
134 verbose_name = _("WebAuthn credential")
135 verbose_name_plural = _("WebAuthn credentials")
137 objects = WebAuthnCredentialManager.from_queryset(WebAuthnCredentialQuerySet)()
139 def natural_key(self):
140 return (self.credential_id_sha256,)
142 # The following fields are necessary or recommended by the WebAuthn L3 specification.
143 # https://www.w3.org/TR/webauthn-3/#credential-record
144 class CredentialType(models.TextChoices):
145 PUBLIC_KEY = "public-key", _("Public Key")
147 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-type
148 # Always set to "public-key" but here for forward compatibility, as recommended by the spec.
149 credential_type = models.CharField(
150 _("credential type"),
151 max_length=32,
152 choices=CredentialType.choices,
153 default=CredentialType.PUBLIC_KEY,
154 editable=False,
155 )
157 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-id
158 credential_id = models.BinaryField(
159 max_length=1023,
160 # We explicitly DO NOT perform a uniqueness check on this field because checking for uniqueness is
161 # often slow (or entirely impossible) for large fields. Instead we rely on the credential_id_sha256 field for enforcing uniqueness.
162 unique=False,
163 verbose_name=_("credential id data"),
164 editable=False,
165 )
166 """Identifier for the credential, created by the client.
168 It is used by the client to discover whether it has a matching credential that can be used to authenticate.
170 Some authenticators store a lot of data in this field, which means that the field can be quite large.
172 Previous revisions of the WebAuthn spec did not mention a maximum size for this field.
173 The L3 revision mentions a maximum size of 1023 bytes.
175 See https://github.com/w3c/webauthn/pull/1664 for related discussion.
176 """
178 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-publickey
179 public_key = models.BinaryField(max_length=1023, verbose_name=_("COSE public key data"), editable=False)
180 """The public key of the credential, encoded in COSE_Key format (binary).
182 Specification: https://www.rfc-editor.org/rfc/rfc9052#section-7
183 """
185 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-transports
186 transports = models.JSONField(_("transports"), editable=False, default=list)
187 """The transports supported by this credential. We keep track of this and
188 send it to the client to allow it to make better decisions about which
189 credential to use when authenticating. For example, if the client knows that
190 the user has a credential that supports USB transport, it can show a message
191 like "Please insert your usb key to authenticate".
193 Fun fact: Some Yubikeys support NFC and USB transports. A Yubikey registered
194 via USB will also be able to authenticate via NFC, and it is nice enough to
195 tell us that it supports this!
196 """
198 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-signcount
199 # Sign count is used to detect cloning attacks. The sign count is
200 # incremented BY THE AUTHENTICATOR every time the credential is used. This has
201 # lost most of its meaning because authenticators that back up to the cloud,
202 # like Apple's iCloud Keychain, are essentially clones and they do not
203 # increment this value. We still keep track of it because it is part of the
204 # specification.
205 sign_count = models.PositiveIntegerField(
206 _("sign count"),
207 default=0,
208 editable=False,
209 )
210 """The number of times this credential has been used. This is used to detect cloning attacks.
212 Note: in practice, this is zero for most cross-platform authenticators like Apple's iCloud Keychain.
213 """
215 # The level 3 specification also recommends a backupEligible and backupState fields.
216 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-backupeligible
217 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-backupstate
218 # The idea the spec proposes is to use the backup fields to determine if a
219 # credential is at risk of being lost. If there is no risk of loss, traditional
220 # password-based authentication could be disabled.
221 #
222 # Related discussion: https://github.com/w3c/webauthn/issues/1692
223 backup_eligible = models.BooleanField(
224 _("backup eligible"),
225 default=False,
226 editable=False,
227 )
228 """Whether this credential can be backed up.
230 This is a hint from the client that the credential implements some mechanism to prevent loss. For example, a cloud backup.
231 """
233 backup_state = models.BooleanField(
234 _("backup state"),
235 default=False,
236 editable=False,
237 )
238 """Whether this credential is currently backed up.
240 This is a hint from the client that the credential is currently backed up,
241 to a cloud account for example. It differs from `backup_eligible` in that
242 it indicates the current state of the backup, not just whether it is
243 possible.
244 """
246 # The spec also recommends a uvInitialized field.
247 # https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-uvinitialized
248 # The idea behind uvInitialized is to keep track of whether this
249 # authenticator supports user verification. Apparently, it is meant to be
250 # used to influence policy decisions though it is unclear to me how this
251 # would work exactly and what benefit it would bring. Our implementation
252 # does not use this field. And because it appears it could be added later
253 # without too much difficulty, we do not implement it yet.
255 aaguid = models.CharField(
256 max_length=36,
257 verbose_name=_("AAGUID"),
258 editable=False,
259 )
260 """The AAGUID of the authenticator. It can be used to identify the make and
261 model of the authenticator but is often zeroed out to protect user
262 privacy.
264 You may use this field to identify the authenticator, if you are able to.
266 The FIDO Alliance maintains a metadata service that may be of use:
267 https://fidoalliance.org/metadata/
269 Check out this community-maintained list of known AAGUIDs:
270 https://github.com/passkeydeveloper/passkey-authenticator-aaguids
271 """
273 credential_id_sha256 = models.BinaryField(
274 _("hashed credential id"),
275 max_length=32,
276 editable=False,
277 unique=True,
278 )
279 """SHA256 hash of the credential ID. It is used to speed up lookups for a
280 given credential ID only and has no purpose beyond that."""
282 discoverable = models.BooleanField(
283 _("discoverable"),
284 default=None,
285 null=True,
286 editable=False,
287 )
288 """Hint provided by the client upon registration on whether this credential
289 can be used without us - the relying party - having to provide the
290 credential id back to the authenticator.
292 Some authenticators, notably limited memory devices like security keys, will
293 encode data in the credential id field and need that data again to be able
294 to generate signatures. If this is the case, the authenticator cannot be
295 used for passwordless login because we'd need a username to look up the
296 associated credential ids. If we respond with credential ids, this leaks
297 information about the existence of said user account in our system.
299 Non-discoverable authenticators can still be used as a second factor during
300 MFA, as the client has already submitted some proof of identity so we can
301 reasonably provide credential ids back to the client for the authenticator
302 to use.
304 - `None` = unknown, the client did not provide the hint.
305 - `True` = the client hints that the authenticator is usable without
306 providing the credential id. The spec calls this a 'Client-side
307 discoverable Credential Source'
308 - `False` = the client hints that the authenticator cannot function without
309 the credential id. The spec calls this a 'Server-side Public Key
310 Credential Source'
312 For more information see:
313 https://www.w3.org/TR/webauthn-3/#client-side-discoverable-public-key-credential-source
314 """
316 def save(self, *args, **kwargs):
317 if not self.credential_id_sha256:
318 self.credential_id_sha256 = self.get_credential_id_sha256(self.credential_id)
319 super().save(*args, **kwargs)
321 @classmethod
322 def get_by_credential_id(cls, credential_id: bytes) -> "WebAuthnCredential":
323 """Return a WebAuthnCredential instance by its credential id.
325 Will attempt to find a matching device by looking up the hash of the credential id.
326 """
327 hashed_credential_id = cls.get_credential_id_sha256(credential_id)
328 return cls.objects.get(credential_id_sha256=hashed_credential_id)
330 @classmethod
331 def get_credential_id_sha256(cls, credential_id: bytes) -> bytes:
332 """Return the SHA256 hash of the given credential id."""
333 return hashlib.sha256(credential_id).digest()
335 @classmethod
336 def get_credential_descriptors_for_user(cls, user: AbstractUser) -> list[PublicKeyCredentialDescriptor]:
337 """Return a list of PublicKeyCredentialDescriptor objects for the given user.
339 Each PublicKeyCredentialDescriptor object represents a credential that the
340 user has registered.
342 These descriptors are intended to inform the client about credential the
343 user has registered with the server.
344 """
346 queryset = (
347 cls.objects.filter(user=user)
348 # The ordering caries significance. Clients MAY use the order of the
349 # list to determine the order in which to display suggested options
350 # to the user. We don't explicitly keep track of preferred devices,
351 # but we can make the assumption that recently used devices
352 # are more likely to be a preferred devices.
353 # Source: https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials
354 # > The list is ordered in descending order of preference: the first
355 # > item in the list is the most preferred credential, and the last
356 # > is the least preferred.
357 .order_by(models.F("last_used_at").desc(nulls_last=True))
358 )
360 return as_credential_descriptors(queryset)
362 @classmethod
363 def get_webauthn_helper(cls, request: HttpRequest):
364 """Return the WebAuthnHelper class instance for this device."""
366 helper = import_string(app_settings.OTP_WEBAUTHN_HELPER_CLASS)
367 return helper(request=request)
370class WebAuthnCredential(AbstractWebAuthnCredential):
371 """A OTP device that validates against a user's credential.
373 See https://www.w3.org/TR/webauthn-3/ for more information about the FIDO 2
374 Web Authentication standard.
375 """
377 pass
380class WebAuthnAttestation(AbstractWebAuthnAttestation):
381 """Model to store attestation for registered credentials for future reference"""
383 pass