Coverage for src/django_otp_webauthn/helpers.py: 0%

155 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-06-23 20:15 +0000

1import hashlib 

2import json 

3from typing import Optional 

4 

5from django.contrib.auth import get_user_model 

6from django.contrib.auth.models import AbstractUser 

7from django.http import HttpRequest 

8from webauthn import ( 

9 base64url_to_bytes, 

10 generate_authentication_options, 

11 generate_registration_options, 

12 options_to_json, 

13 verify_authentication_response, 

14 verify_registration_response, 

15) 

16from webauthn.helpers import ( 

17 generate_challenge, 

18 parse_attestation_object, 

19 parse_authentication_credential_json, 

20 parse_registration_credential_json, 

21) 

22from webauthn.helpers.structs import ( 

23 AttestationConveyancePreference, 

24 AuthenticatorAttachment, 

25 AuthenticatorSelectionCriteria, 

26 COSEAlgorithmIdentifier, 

27 CredentialDeviceType, 

28 PublicKeyCredentialRpEntity, 

29 PublicKeyCredentialUserEntity, 

30 RegistrationCredential, 

31 ResidentKeyRequirement, 

32 UserVerificationRequirement, 

33) 

34from webauthn.registration.verify_registration_response import VerifiedRegistration 

35 

36from django_otp_webauthn import exceptions 

37from django_otp_webauthn.models import ( 

38 AbstractWebAuthnAttestation, 

39 AbstractWebAuthnCredential, 

40) 

41from django_otp_webauthn.settings import app_settings 

42from django_otp_webauthn.utils import get_attestation_model, get_credential_model 

43 

44User = get_user_model() 

45WebAuthnCredential = get_credential_model() 

46WebAuthnAttestation = get_attestation_model() 

47 

48 

49class WebAuthnHelper: 

50 """A wrapper class around the PyWebAuthn library.""" 

51 

52 def __init__(self, request: HttpRequest): 

53 self.request = request 

54 

55 def generate_challenge(self) -> bytes: 

56 """Generate a challenge for the client to sign.""" 

57 return generate_challenge() 

58 

59 def get_relying_party_domain(self) -> str: 

60 """Get the domain of the relying party. 

61 

62 This is the domain of your website or company. Like "acme.com". 

63 

64 The default implementation reads the setting `OTP_WEBAUTHN_RP_ID`. 

65 

66 See also: https://www.w3.org/TR/webauthn-3/#rp-id 

67 """ 

68 # The domain for the relying party. The WebAuthn spec calls this the RP 

69 # ID. 

70 if app_settings.OTP_WEBAUTHN_RP_ID_CALLABLE: 

71 func = app_settings._get_callable_setting("OTP_WEBAUTHN_RP_ID_CALLABLE") 

72 return func(request=self.request) 

73 return app_settings.OTP_WEBAUTHN_RP_ID 

74 

75 def get_relying_party_name(self) -> str: 

76 """Get the name of the relying party. This is the name of your website 

77 or company. Like "Acme Corporation". 

78 

79 This is sometimes displayed to the user during credential registration. 

80 ('do you want to register a credential with Acme Corporation?') 

81 

82 The default implementation reads the setting `OTP_WEBAUTHN_RP_NAME`. 

83 

84 See also: https://www.w3.org/TR/webauthn-3/#rp-name 

85 """ 

86 if app_settings.OTP_WEBAUTHN_RP_NAME_CALLABLE: 

87 func = app_settings._get_callable_setting("OTP_WEBAUTHN_RP_NAME_CALLABLE") 

88 return func(request=self.request) 

89 return app_settings.OTP_WEBAUTHN_RP_NAME 

90 

91 def get_relying_party(self) -> PublicKeyCredentialRpEntity: 

92 """Get the relying party entity.""" 

93 return PublicKeyCredentialRpEntity( 

94 name=self.get_relying_party_name(), 

95 id=self.get_relying_party_domain(), 

96 ) 

97 

98 def get_discoverable_credentials_preference(self) -> ResidentKeyRequirement: 

99 """Determines if we'd like the authenticator to store the credential in 

100 authenticator memory instead of encrypting and storing it on the the 

101 server. When stored on the server, the private key is encrypted and 

102 encoded in the credential ID in an opaque format. In such a case, the 

103 server must provide the credential ID back to the client before the 

104 client can authenticate using the credential. 

105 

106 Storing the credential in the authenticator makes it possible to do full 

107 passwordless authentication, without the relying party having to provide 

108 all credential IDs for a given user. Providing credentials to 

109 unauthenticated users leaks information about the existence of the user 

110 and their registered credential. 

111 

112 By default, this is set to 'preferred', which means we'd like the 

113 authenticator to store the private key + metadata in persistent 

114 authenticator memory if possible, but it's not required. 

115 

116 Some devices - like security keys - have limited memory and can't store 

117 the private key and associated metadata for more than a couple 

118 credentials. This is why we don't set this to 'required' by default. 

119 

120 Noteworthy is that some authenticators (like Apple iCloud and Android) 

121 will always create discoverable credentials. Even if you set this to 

122 'discouraged'. 

123 

124 **Note**: historically, this was referred to as "resident keys". 

125 Resident keys mean the same thing as "discoverable credentials". 

126 Resident key is now an outdated term used in older versions of the 

127 WebAuthn spec but it still lives on in some places. This library 

128 attempts to apply the term "discoverable credentials" consistently. 

129 

130 For more information about discoverable credentials / resident keys see: 

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

132 - https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/Resident_Keys.html 

133 """ 

134 

135 # If passwordless login is allowed, we require discoverable credentials 

136 # because non-discoverable credentials can't be used for 

137 # passwordless login. Users expect to be able to log in without a 

138 # password if we show them that option. Why some passkeys don't work 

139 # will cause confusion. Don't allow these devices to be registered in 

140 # the first place. 

141 if app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN: 

142 return ResidentKeyRequirement.REQUIRED 

143 

144 # What 'preferred' means depends entirely on the browser. 

145 # Some browsers will prefer creating discoverable credentials and some 

146 # just don't. Firefox is known to not create discoverable credentials. 

147 return ResidentKeyRequirement.PREFERRED 

148 

149 def get_attestation_conveyance_preference(self) -> AttestationConveyancePreference: 

150 """Determines if we'd like the authenticator to send an attestation statement. 

151 

152 By default this is set to "none", which means we don't want the 

153 authenticator to send an attestation statement. 

154 

155 The attestation statement is a signed statement from the authenticator 

156 that can be used to verify the authenticity of the authenticator. It can 

157 help answer the following question: "is this authenticator really from 

158 the manufacturer it claims to be from?". 

159 

160 An example use case of this could be to ensure only authenticators from 

161 a certain manufacturer are allowed to register - if that were a 

162 requirement for your application. 

163 

164 This package uses py_webauthn which supports verifying attestation 

165 statements. If you want to use this feature, you can set this to 

166 "indirect" or "direct". 

167 

168 For more information about attestation, see: - 

169 https://www.w3.org/TR/webauthn-2/#attestation-conveyance - 

170 https://www.w3.org/TR/webauthn-2/#sctn-attestation - 

171 https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/Attestation.html 

172 """ 

173 return AttestationConveyancePreference.NONE 

174 

175 def get_authenticator_attachment_preference( 

176 self, 

177 ) -> Optional[AuthenticatorAttachment]: 

178 """Get the authenticator attachment preference. 

179 

180 By default, this is set to None, which means we don't have a preference. 

181 

182 For more information about authenticator attachment, see: 

183 - https://www.w3.org/TR/webauthn-2/#enumdef-authenticatorattachment 

184 """ 

185 return None 

186 

187 def get_credential_display_name(self, user: AbstractUser) -> str: 

188 """Get the display name for the credential. 

189 

190 This is used to display a name during registration and authentication. 

191 

192 The default implementation calls User.get_full_name() and falls back to 

193 User.get_username(). 

194 """ 

195 

196 if user.get_full_name(): 

197 return f"{user.get_full_name()} ({user.get_username()})" 

198 

199 return user.get_username() 

200 

201 def get_credential_name(self, user: AbstractUser) -> str: 

202 """Get the name for the credential. 

203 

204 This is used to display the user's name during registration and 

205 authentication. 

206 

207 The default implementation calls User.get_username() 

208 """ 

209 return user.get_username() 

210 

211 def get_unique_anonymous_user_id(self, user: AbstractUser) -> bytes: 

212 """Get a unique identifier for the user to use during WebAuthn 

213 ceremonies. It must be a unique byte sequence no longer than 64 bytes. 

214 

215 To preserve user privacy, it must not contain any information that may 

216 lead to the identification of the user. UUIDs may be a good choice for 

217 this, plain email addresses and usernames are definitely not. 

218 

219 Clients can use this to identify if they already have a credential 

220 stored for this user account and act accordingly. 

221 

222 For more information, see: - 

223 https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id 

224 """ 

225 # Because we lack a dedicated field to store random bytes in on the user 

226 # model, we'll instead resort to hashing the user's primary key as that 

227 # is unique too and will never change. Since this value doesn't have to 

228 # be unique across different relying parties, we don't need to salt it. 

229 # SHA-256 is used because it is fast, commonly used, and produces a 

230 # 64-byte hash. This is good enough privacy, though not as perfect as 

231 # random bytes. 

232 # SECURITY NOTE: The attack vector for de-anonymizing by 

233 # linking authenticator back to a specific user is small, but still 

234 # present. If an attacker suspects the authenticator belongs to a 

235 # specific user, they can obtain the suspected user's primary key and 

236 # hash it to see if it matches the user ID stored on the authenticator. 

237 # Random bytes never shared with anyone don't have this issue. 

238 # TODO: document the need to override this method and to use random 

239 # bytes instead. 

240 return hashlib.sha256(bytes(user.pk)).digest() 

241 

242 def get_user_entity(self, user: AbstractUser) -> PublicKeyCredentialUserEntity: 

243 """Get information about the user account a credential is being registered for.""" 

244 return PublicKeyCredentialUserEntity( 

245 id=self.get_unique_anonymous_user_id(user), 

246 name=self.get_credential_name(user), 

247 display_name=self.get_credential_display_name(user), 

248 ) 

249 

250 def get_supported_key_algorithms(self) -> list[COSEAlgorithmIdentifier] | None: 

251 """Get the key algorithms we support. 

252 

253 Should return a list of COSE algorithm identifiers that we support. Or 

254 the special value of `None` to default to py_webauthn's algorithm list. 

255 

256 For example, to only support ECDSA_SHA_256 and ECDSA_SHA_512, you would 

257 return [-7, -36] as those are the COSE algorithm identifiers for those 

258 algorithms. 

259 

260 For more information, see: - 

261 https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier - 

262 https://www.iana.org/assignments/cose/cose.xhtml#algorithms 

263 """ 

264 

265 raw_algorithms = app_settings.OTP_WEBAUTHN_SUPPORTED_COSE_ALGORITHMS 

266 if raw_algorithms == "all": 

267 # Indicates all py_webauthn supported algorithms 

268 return None 

269 

270 algorithms = [COSEAlgorithmIdentifier(a) for a in raw_algorithms if a in COSEAlgorithmIdentifier] 

271 return algorithms 

272 

273 def get_generate_registration_options_kwargs(self, *, user: AbstractUser) -> dict: 

274 """Get the keyword arguments to pass to `webauthn.generate_registration_options`.""" 

275 challenge = self.generate_challenge() 

276 rp = self.get_relying_party() 

277 user_entity = self.get_user_entity(user) 

278 attestation_preference = self.get_attestation_conveyance_preference() 

279 discoverable_credentials = self.get_discoverable_credentials_preference() 

280 user_verification = UserVerificationRequirement.PREFERRED 

281 exclude_credentials = WebAuthnCredential.get_credential_descriptors_for_user(user) 

282 supported_algorithms = self.get_supported_key_algorithms() 

283 authenticator_selection = AuthenticatorSelectionCriteria( 

284 user_verification=user_verification, resident_key=discoverable_credentials 

285 ) 

286 

287 options = { 

288 "attestation": attestation_preference, 

289 "authenticator_selection": authenticator_selection, 

290 "challenge": challenge, 

291 "exclude_credentials": exclude_credentials, 

292 "rp_id": rp.id, 

293 "rp_name": rp.name, 

294 "user_display_name": user_entity.display_name, 

295 "user_id": user_entity.id, 

296 "user_name": user_entity.name, 

297 # Timeout is in milliseconds, but the setting is in seconds 

298 "timeout": app_settings.OTP_WEBAUTHN_TIMEOUT_SECONDS * 1000, 

299 } 

300 

301 if supported_algorithms: 

302 options["supported_pub_key_algs"] = supported_algorithms 

303 

304 return options 

305 

306 def get_registration_extensions(self) -> dict: 

307 """Get the extensions to request during registration. Data must be JSON serializable.""" 

308 return { 

309 # https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/WebAuthn_extensions#credprops 

310 # Request that the authenticator tell us if a discoverable 

311 # credential was created. We keep track of this to determine if 

312 # passwordless authentication is possible using this credential. 

313 "credProps": True, 

314 } 

315 

316 def get_registration_state(self, creation_options: dict) -> dict: 

317 """Get the state to store during registration. This state will be used 

318 to verify the registration later on.""" 

319 

320 return { 

321 "challenge": creation_options["challenge"], 

322 "require_user_verification": creation_options["authenticatorSelection"]["userVerification"] 

323 == UserVerificationRequirement.REQUIRED.value, 

324 } 

325 

326 def register_begin(self, user: AbstractUser) -> tuple[dict, dict]: 

327 """Begin the registration process.""" 

328 

329 kwargs = self.get_generate_registration_options_kwargs(user=user) 

330 options = generate_registration_options(**kwargs) 

331 

332 json_string = options_to_json(options) 

333 # We work with dicts and not JSON strings, so we need to load the JSON 

334 # string again. Sadly this causes extra overhead but it'll have to do 

335 # for now. 

336 data = json.loads(json_string) 

337 

338 # Manually add the extensions to the options, as the PyWebAuthn library 

339 # doesn't support this yet. 

340 extensions = self.get_registration_extensions() 

341 data["extensions"] = extensions 

342 

343 state = self.get_registration_state(data) 

344 

345 return data, state 

346 

347 def get_allowed_origins(self) -> list[str]: 

348 """Get the expected origins.""" 

349 origins = app_settings.OTP_WEBAUTHN_ALLOWED_ORIGINS 

350 return origins 

351 

352 def register_complete(self, user: AbstractUser, state: dict, data: dict): 

353 """Complete the registration process.""" 

354 credential = parse_registration_credential_json(data) 

355 

356 expected_challenge = base64url_to_bytes(state["challenge"]) 

357 allowed_origins = self.get_allowed_origins() 

358 require_user_verification = state["require_user_verification"] 

359 expected_rp_id = self.get_relying_party_domain() 

360 supported_algorithms = self.get_supported_key_algorithms() 

361 

362 kwargs = {} 

363 if supported_algorithms: 

364 kwargs["supported_pub_key_algs"] = supported_algorithms 

365 

366 response = verify_registration_response( 

367 credential=credential, 

368 expected_challenge=expected_challenge, 

369 expected_rp_id=expected_rp_id, 

370 expected_origin=allowed_origins, 

371 require_user_verification=require_user_verification, 

372 **kwargs, 

373 ) 

374 

375 device = self.create_credential(user, response, credential, data) 

376 device.save() 

377 self.create_attestation(device, response.attestation_object, credential.response.client_data_json) 

378 return device 

379 

380 def _check_discoverable(self, original_data: dict) -> Optional[bool]: 

381 """Check the clientExtensionResults to determine if the credential was 

382 created as discoverable. 

383 

384 SECURITY NOTE: clientExtensionResults is not signed by the 

385 authenticator and is open to tampering by a malicious client. Since we 

386 are only using it to inform the user and not to make security decisions, 

387 this is fine. 

388 """ 

389 if ( 

390 "clientExtensionResults" not in original_data 

391 or "credProps" not in original_data["clientExtensionResults"] 

392 or "rk" not in original_data["clientExtensionResults"]["credProps"] 

393 ): 

394 return None 

395 

396 return bool(original_data["clientExtensionResults"]["credProps"]["rk"]) 

397 

398 def create_credential( 

399 self, 

400 user: AbstractUser, 

401 response: VerifiedRegistration, 

402 parsed_credential: RegistrationCredential, 

403 original_data: dict, 

404 ): 

405 """Save the credential to the database.""" 

406 discoverable = self._check_discoverable(original_data) 

407 transports = ( 

408 [x.value for x in parsed_credential.response.transports] if parsed_credential.response.transports else [] 

409 ) 

410 

411 # We can't use the backup_eligible flag directly because it's not 

412 # exposed in the py_webauthn API. We can however infer it from the 

413 # credential device type. If it's a multi-device credential, it's backup 

414 # eligible. 

415 backup_eligible = response.credential_device_type == CredentialDeviceType.MULTI_DEVICE 

416 backup_state = response.credential_backed_up 

417 

418 device = WebAuthnCredential( 

419 user=user, 

420 aaguid=response.aaguid, 

421 credential_id=response.credential_id, 

422 public_key=response.credential_public_key, 

423 sign_count=response.sign_count, 

424 transports=transports, 

425 discoverable=discoverable, 

426 backup_eligible=backup_eligible, 

427 backup_state=backup_state, 

428 ) 

429 # We don't save the device yet, this could result in errors if a custom 

430 # model has added additional fields. The device will be saved by the 

431 # caller. This method can be extended to set additional fields on the 

432 # device before saving. 

433 return device 

434 

435 def create_attestation( 

436 self, 

437 credential: AbstractWebAuthnCredential, 

438 attestation_object: bytes, 

439 client_data_json: bytes, 

440 ) -> AbstractWebAuthnAttestation: 

441 """Create an attestation statement for the device.""" 

442 parsed = parse_attestation_object(attestation_object) 

443 

444 return WebAuthnAttestation.objects.create( 

445 credential=credential, 

446 fmt=parsed.fmt, 

447 data=attestation_object, 

448 client_data_json=client_data_json, 

449 ) 

450 

451 def get_authentication_extensions(self) -> dict: 

452 """Get the extensions to request during authentication. Data must be 

453 JSON serializable.""" 

454 return {} 

455 

456 def get_generate_authentication_options_kwargs( 

457 self, *, user: Optional[AbstractUser] = None, require_user_verification: bool 

458 ) -> dict: 

459 """Get the keyword arguments to pass to `webauth.generate_authentication_options`.""" 

460 

461 kwargs = { 

462 "challenge": generate_challenge(), 

463 "rp_id": self.get_relying_party_domain(), 

464 "timeout": app_settings.OTP_WEBAUTHN_TIMEOUT_SECONDS * 1000, 

465 "user_verification": ( 

466 UserVerificationRequirement.REQUIRED 

467 if require_user_verification 

468 else UserVerificationRequirement.DISCOURAGED 

469 ), 

470 } 

471 

472 if user: 

473 kwargs["allow_credentials"] = WebAuthnCredential.get_credential_descriptors_for_user(user) 

474 

475 return kwargs 

476 

477 def get_authentication_state(self, options: dict) -> dict: 

478 """Get the state to store during authentication. This state will be used 

479 to verify the authentication later on.""" 

480 return { 

481 "challenge": options["challenge"], 

482 "require_user_verification": options["userVerification"] == UserVerificationRequirement.REQUIRED.value, 

483 } 

484 

485 def authenticate_begin( 

486 self, 

487 user: Optional[AbstractUser] = None, 

488 require_user_verification: bool = True, 

489 ): 

490 """Begin the authentication process.""" 

491 kwargs = self.get_generate_authentication_options_kwargs( 

492 user=user, require_user_verification=require_user_verification 

493 ) 

494 options = generate_authentication_options(**kwargs) 

495 

496 json_string = options_to_json(options) 

497 # We work with dicts and not JSON strings, so we need to load the JSON 

498 # string again. Sadly this causes extra overhead but it'll have to do 

499 # for now. 

500 data = json.loads(json_string) 

501 

502 # Manually add the extensions to the options, as the PyWebAuthn library 

503 # doesn't support this yet. 

504 extensions = self.get_authentication_extensions() 

505 data["extensions"] = extensions 

506 

507 state = self.get_authentication_state(data) 

508 return data, state 

509 

510 def authenticate_complete(self, user: Optional[AbstractUser], state: dict, data: dict): 

511 """Complete the authentication process.""" 

512 

513 credential = parse_authentication_credential_json(data) 

514 

515 try: 

516 device = WebAuthnCredential.get_by_credential_id(credential.raw_id) 

517 except WebAuthnCredential.DoesNotExist: 

518 raise exceptions.CredentialNotFound() 

519 

520 expected_challenge = base64url_to_bytes(state["challenge"]) 

521 expected_origins = self.get_allowed_origins() 

522 require_user_verification = state["require_user_verification"] 

523 expected_rp_id = self.get_relying_party_domain() 

524 supported_algorithms = self.get_supported_key_algorithms() 

525 

526 kwargs = {} 

527 if supported_algorithms: 

528 kwargs["supported_algorithms"] = supported_algorithms 

529 

530 response = verify_authentication_response( 

531 credential=credential, 

532 credential_current_sign_count=device.sign_count, 

533 credential_public_key=device.public_key, 

534 expected_challenge=expected_challenge, 

535 expected_rp_id=expected_rp_id, 

536 expected_origin=expected_origins, 

537 require_user_verification=require_user_verification, 

538 **kwargs, 

539 ) 

540 

541 device.sign_count = response.new_sign_count 

542 device.backup_state = response.credential_backed_up 

543 device.set_last_used_timestamp(commit=False) 

544 device.save(update_fields=["sign_count", "last_used_at", "backup_state"]) 

545 

546 return device