Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce WebAuthnUserHandle to persist a truly random user handle #44

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Noteworthy:** the way WebAuthn user handles are generated has been changed to make them more privacy-friendly. There should be no adverse backward-compatibility issues. ([#44](https://github.com/Stormbase/django-otp-webauthn/pull/4) by [Stormheg](https://github.com/Stormheg))
- The default JavaScript implementation is now built using Node 22
- The default JavaScript implementation for interacting with the browser api has been updated to use [`@simplewebauthn/browser` v11](https://github.com/MasterKale/SimpleWebAuthn/releases/tag/v11.0.0)

Expand Down
5 changes: 4 additions & 1 deletion sandbox/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-05-12 13:16+0000\n"
"POT-Creation-Date: 2024-12-01 17:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down Expand Up @@ -41,12 +41,15 @@ msgstr "Log in"

#: sandbox/templates/django_admin_login.html:85
#: sandbox/templates/registration/login.html:21
#: sandbox/templates/sandbox/login_passkey.html:5
#: sandbox/templates/sandbox/login_passkey.html:13
msgid "Login using a Passkey"
msgstr "Inloggen met een Passkey"

#: sandbox/templates/django_admin_login.html:91
#: sandbox/templates/registration/login.html:27
#: sandbox/templates/sandbox/index.html:31
#: sandbox/templates/sandbox/login_passkey.html:18
msgid "Sorry, your browser has no Passkey support"
msgstr "Sorry, je browser heeft geen Passkey-ondersteuning"

Expand Down
35 changes: 2 additions & 33 deletions src/django_otp_webauthn/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json

from django.contrib.auth import get_user_model
Expand Down Expand Up @@ -38,6 +37,7 @@
from django_otp_webauthn.models import (
AbstractWebAuthnAttestation,
AbstractWebAuthnCredential,
WebAuthnUserHandle,
)
from django_otp_webauthn.settings import app_settings
from django_otp_webauthn.utils import get_attestation_model, get_credential_model
Expand Down Expand Up @@ -209,41 +209,10 @@ def get_credential_name(self, user: AbstractBaseUser) -> str:
"""
return user.get_username()

def get_unique_anonymous_user_id(self, user: AbstractBaseUser) -> bytes:
"""Get a unique identifier for the user to use during WebAuthn
ceremonies. It must be a unique byte sequence no longer than 64 bytes.

To preserve user privacy, it must not contain any information that may
lead to the identification of the user. UUIDs may be a good choice for
this, plain email addresses and usernames are definitely not.

Clients can use this to identify if they already have a credential
stored for this user account and act accordingly.

For more information, see:
https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id
"""
# Because we lack a dedicated field to store random bytes on the user
# model, we'll instead resort to hashing the user's primary key as that
# is unique too and will never change. Since this value doesn't have to
# be unique across different relying parties, we don't need to salt it.
# SHA-256 is used because it is fast, commonly used, and produces a
# 64-byte hash. This is good enough privacy, though not as perfect as
# random bytes.
# SECURITY NOTE: The attack vector for de-anonymizing by
# linking authenticator back to a specific user is small, but still
# present. If an attacker suspects the authenticator belongs to a
# specific user, they can obtain the suspected user's primary key and
# hash it to see if it matches the user ID stored on the authenticator.
# Random bytes never shared with anyone don't have this issue.
# TODO: document the need to override this method and to use random
# bytes instead.
return hashlib.sha256(bytes(user.pk)).digest()

def get_user_entity(self, user: AbstractBaseUser) -> PublicKeyCredentialUserEntity:
"""Get information about the user account a credential is being registered for."""
return PublicKeyCredentialUserEntity(
id=self.get_unique_anonymous_user_id(user),
id=WebAuthnUserHandle.get_handle_by_user(user),
name=self.get_credential_name(user),
display_name=self.get_credential_display_name(user),
)
Expand Down
92 changes: 54 additions & 38 deletions src/django_otp_webauthn/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-03 13:14+0000\n"
"POT-Creation-Date: 2024-12-01 17:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand All @@ -18,128 +18,144 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

#: src/django_otp_webauthn/admin.py:42
#: src/django_otp_webauthn/admin.py:41
msgid "COSE public key"
msgstr "Publieke COSE-sleutel"

#: src/django_otp_webauthn/admin.py:62
#: src/django_otp_webauthn/admin.py:63
msgid "Identity"
msgstr "Identiteit"

#: src/django_otp_webauthn/admin.py:68
#: src/django_otp_webauthn/admin.py:69
msgid "Meta"
msgstr "Meta-gegevens"

#: src/django_otp_webauthn/admin.py:74
#: src/django_otp_webauthn/admin.py:75
msgid "WebAuthn credential data"
msgstr "WebAuthn-gegevens"

#: src/django_otp_webauthn/apps.py:11
msgid "OTP WebAuthn"
msgstr "OTP WebAuthn"

#: src/django_otp_webauthn/exceptions.py:11
#: src/django_otp_webauthn/exceptions.py:12
msgid ""
"State is missing or invalid. Please begin the operation first before trying "
"to complete it."
msgstr ""
"Staat ontbreekt of is ongeldig. Begin de operatie eerst voordat u probeert "
"deze te voltooien."

#: src/django_otp_webauthn/exceptions.py:17
#: src/django_otp_webauthn/exceptions.py:19
msgid "Unprocessable Entity"
msgstr "Onverwerkbare entiteit"

#: src/django_otp_webauthn/exceptions.py:23
#: src/django_otp_webauthn/exceptions.py:25
msgid "Passwordless login is disabled."
msgstr "Wachtwoordloze login is uitgeschakeld."

#: src/django_otp_webauthn/exceptions.py:29
#: src/django_otp_webauthn/exceptions.py:31
msgid "This user account is marked as disabled."
msgstr "Dit gebruikersaccount is gemarkeerd als uitgeschakeld."

#: src/django_otp_webauthn/exceptions.py:35
#: src/django_otp_webauthn/exceptions.py:37
msgid "This Passkey has been marked as disabled."
msgstr "Deze Passkey is gemarkeerd als uitgeschakeld."

#: src/django_otp_webauthn/exceptions.py:41
#: src/django_otp_webauthn/exceptions.py:44
msgid "The Passkey you tried to use was not found. Perhaps it was removed?"
msgstr ""
"De Passkey die u probeert te gebruiken is niet gevonden. Misschien is deze "
"verwijderd?"

#: src/django_otp_webauthn/models.py:71
msgid "credential"
msgstr "credential"
#: src/django_otp_webauthn/models.py:79
msgid "WebAuthn attestation"
msgstr "WebAuthn attestatie"

#: src/django_otp_webauthn/models.py:80
msgid "WebAuthn attestations"
msgstr "WebAuthn attestaties"

#: src/django_otp_webauthn/models.py:75
#: src/django_otp_webauthn/models.py:96
msgid "format"
msgstr "formaat"

#: src/django_otp_webauthn/models.py:78
#: src/django_otp_webauthn/models.py:100
msgid "data"
msgstr "data"

#: src/django_otp_webauthn/models.py:81
#: src/django_otp_webauthn/models.py:104
msgid "client data JSON"
msgstr "client data JSON"

#: src/django_otp_webauthn/models.py:98
msgid "WebAuthn attestation"
msgstr "WebAuthn attestatie"

#: src/django_otp_webauthn/models.py:99
msgid "WebAuthn attestations"
msgstr "WebAuthn attestaties"

#: src/django_otp_webauthn/models.py:122
#: src/django_otp_webauthn/models.py:148
msgid "WebAuthn credential"
msgstr "WebAuthn credential"

#: src/django_otp_webauthn/models.py:123
#: src/django_otp_webauthn/models.py:149
msgid "WebAuthn credentials"
msgstr "WebAuthn credentials"

#: src/django_otp_webauthn/models.py:136
#: src/django_otp_webauthn/models.py:156
msgid "Public Key"
msgstr "Publieke Sleutel"

#: src/django_otp_webauthn/models.py:141
#: src/django_otp_webauthn/models.py:161
msgid "credential type"
msgstr "type credential"

#: src/django_otp_webauthn/models.py:154
#: src/django_otp_webauthn/models.py:174
msgid "credential id data"
msgstr "credential id data"

#: src/django_otp_webauthn/models.py:170
#: src/django_otp_webauthn/models.py:191
msgid "COSE public key data"
msgstr "COSE publieke sleutel data"

#: src/django_otp_webauthn/models.py:177
#: src/django_otp_webauthn/models.py:199
msgid "transports"
msgstr "transports"

#: src/django_otp_webauthn/models.py:197
#: src/django_otp_webauthn/models.py:219
msgid "sign count"
msgstr "aantal ondertekeningen"

#: src/django_otp_webauthn/models.py:215
#: src/django_otp_webauthn/models.py:237
msgid "backup eligible"
msgstr "kan worden geback-upt"

#: src/django_otp_webauthn/models.py:225
#: src/django_otp_webauthn/models.py:247
msgid "backup state"
msgstr "back-up status"

#: src/django_otp_webauthn/models.py:248
#: src/django_otp_webauthn/models.py:270
msgid "AAGUID"
msgstr "AAGUID"

#: src/django_otp_webauthn/models.py:265
#: src/django_otp_webauthn/models.py:287
msgid "hashed credential id"
msgstr "gehashte credential id"

#: src/django_otp_webauthn/models.py:275
#: src/django_otp_webauthn/models.py:296
msgid "discoverable"
msgstr "zichtbaar"

#: src/django_otp_webauthn/models.py:404
msgid "credential"
msgstr "credential"

#: src/django_otp_webauthn/models.py:429
msgid "WebAuthn user handle"
msgstr "WebAuthn user handle"

#: src/django_otp_webauthn/models.py:430
msgid "WebAuthn user handles"
msgstr "WebAuthn user handles"

#: src/django_otp_webauthn/models.py:434
msgid "handle hex"
msgstr "handle hex"

#: src/django_otp_webauthn/models.py:444
msgid "user"
msgstr "gebruiker"
70 changes: 70 additions & 0 deletions src/django_otp_webauthn/migrations/0004_webauthnuserhandle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Generated by Django 5.1.3 on 2024-12-01 16:18

import hashlib

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


def add_handle_for_users_with_existing_credentials(apps, schema_editor):
WebAuthnUserHandle = apps.get_model("django_otp_webauthn", "WebAuthnUserHandle")
WebAuthnCredential = apps.get_model("django_otp_webauthn", "WebAuthnCredential")

# For users that already have credentials, create a handle object based on
# what the user handle must have been at the time of registration. This is
# important, otherwise browsers will consider the credential to belong to a
# different user.
for user_id in WebAuthnCredential.objects.values_list(
"user_id", flat=True
).distinct():
WebAuthnUserHandle.objects.create(
user_id=user_id, handle_hex=hashlib.sha256(bytes(user_id)).digest().hex()
)


class Migration(migrations.Migration):
dependencies = [
(
"django_otp_webauthn",
"0001_squashed_0003_webauthncredential_hash_binary_to_hex",
),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="WebAuthnUserHandle",
fields=[
(
"handle_hex",
models.CharField(
editable=False,
max_length=128,
unique=True,
verbose_name="handle hex",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="webauthn_user_handle",
serialize=False,
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "WebAuthn user handle",
"verbose_name_plural": "WebAuthn user handles",
},
),
migrations.RunPython(
add_handle_for_users_with_existing_credentials,
migrations.RunPython.noop,
elidable=True,
),
]
Loading