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

1import hashlib 

2 

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) 

18 

19from django_otp_webauthn.settings import app_settings 

20from django_otp_webauthn.utils import get_credential_model_string 

21 

22User = get_user_model() 

23 

24 

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. 

34 

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 

46 

47 

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) 

51 

52 

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) 

56 

57 

58class WebAuthnCredentialQuerySet(models.QuerySet): 

59 def as_credential_descriptors(self): 

60 return as_credential_descriptors(self) 

61 

62 

63class AbstractWebAuthnAttestation(models.Model): 

64 """Abstract model to store attestation for registered credentials for future reference. 

65 

66 This model is used to store the attestation object returned by the authenticator during registration. 

67 

68 See https://www.w3.org/TR/webauthn-3/#sctn-attestation for more information about attestation. 

69 """ 

70 

71 class Meta: 

72 abstract = True 

73 verbose_name = _("WebAuthn attestation") 

74 verbose_name_plural = _("WebAuthn attestations") 

75 

76 objects = WebAuthnAttestationManager() 

77 

78 def __str__(self): 

79 return f"{self.credential} (fmt={self.fmt})" 

80 

81 def natural_key(self): 

82 return (self.credential.credential_id_sha256,) 

83 

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" 

91 

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 ) 

99 

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.""" 

102 

103 data = models.BinaryField(verbose_name=_("data"), editable=False) 

104 """The raw attestation data.""" 

105 

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. 

108 

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.""" 

112 

113 @cached_property 

114 def attestation_object(self) -> AttestationObject: 

115 """Return the parsed attestation object.""" 

116 return parse_attestation_object(self.data) 

117 

118 

119class AbstractWebAuthnCredential(TimestampMixin, Device): 

120 """ 

121 Abstract OTP device that validates against a user's WebAuthn credential. 

122 

123 See https://www.w3.org/TR/webauthn-3/ for more information about the FIDO 2 Web Authentication standard. 

124 """ 

125 

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") 

136 

137 objects = WebAuthnCredentialManager.from_queryset(WebAuthnCredentialQuerySet)() 

138 

139 def natural_key(self): 

140 return (self.credential_id_sha256,) 

141 

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") 

146 

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 ) 

156 

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. 

167 

168 It is used by the client to discover whether it has a matching credential that can be used to authenticate. 

169 

170 Some authenticators store a lot of data in this field, which means that the field can be quite large. 

171 

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. 

174 

175 See https://github.com/w3c/webauthn/pull/1664 for related discussion. 

176 """ 

177 

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). 

181 

182 Specification: https://www.rfc-editor.org/rfc/rfc9052#section-7 

183 """ 

184 

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". 

192 

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 """ 

197 

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. 

211 

212 Note: in practice, this is zero for most cross-platform authenticators like Apple's iCloud Keychain. 

213 """ 

214 

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. 

229 

230 This is a hint from the client that the credential implements some mechanism to prevent loss. For example, a cloud backup. 

231 """ 

232 

233 backup_state = models.BooleanField( 

234 _("backup state"), 

235 default=False, 

236 editable=False, 

237 ) 

238 """Whether this credential is currently backed up. 

239 

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 """ 

245 

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. 

254 

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. 

263 

264 You may use this field to identify the authenticator, if you are able to. 

265 

266 The FIDO Alliance maintains a metadata service that may be of use: 

267 https://fidoalliance.org/metadata/ 

268 

269 Check out this community-maintained list of known AAGUIDs: 

270 https://github.com/passkeydeveloper/passkey-authenticator-aaguids 

271 """ 

272 

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.""" 

281 

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. 

291 

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. 

298 

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. 

303 

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' 

311 

312 For more information see: 

313 https://www.w3.org/TR/webauthn-3/#client-side-discoverable-public-key-credential-source 

314 """ 

315 

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) 

320 

321 @classmethod 

322 def get_by_credential_id(cls, credential_id: bytes) -> "WebAuthnCredential": 

323 """Return a WebAuthnCredential instance by its credential id. 

324 

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) 

329 

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() 

334 

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. 

338 

339 Each PublicKeyCredentialDescriptor object represents a credential that the 

340 user has registered. 

341 

342 These descriptors are intended to inform the client about credential the 

343 user has registered with the server. 

344 """ 

345 

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 ) 

359 

360 return as_credential_descriptors(queryset) 

361 

362 @classmethod 

363 def get_webauthn_helper(cls, request: HttpRequest): 

364 """Return the WebAuthnHelper class instance for this device.""" 

365 

366 helper = import_string(app_settings.OTP_WEBAUTHN_HELPER_CLASS) 

367 return helper(request=request) 

368 

369 

370class WebAuthnCredential(AbstractWebAuthnCredential): 

371 """A OTP device that validates against a user's credential. 

372 

373 See https://www.w3.org/TR/webauthn-3/ for more information about the FIDO 2 

374 Web Authentication standard. 

375 """ 

376 

377 pass 

378 

379 

380class WebAuthnAttestation(AbstractWebAuthnAttestation): 

381 """Model to store attestation for registered credentials for future reference""" 

382 

383 pass