Skip to content

Commit

Permalink
Merge pull request #148 from duo-labs/feat/ed25519-support
Browse files Browse the repository at this point in the history
Support use of Ed25519 during registration
  • Loading branch information
MasterKale authored Dec 4, 2024
2 parents 72482fa + 8ccc45e commit fad5eb3
Show file tree
Hide file tree
Showing 10 changed files with 816 additions and 590 deletions.
32 changes: 32 additions & 0 deletions .github/workflows/unit_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Run Unit Tests

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
build:

runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10']

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pipenv
pipenv install --deploy --dev
- name: Test with unittest
run: |
pipenv run _app/manage.py test homepage.tests
- name: Run mypy
run: |
pipenv run mypy _app/homepage
1,110 changes: 534 additions & 576 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion _app/homepage/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class RegistrationOptionsRequestForm(forms.Form):
],
)
algorithms = forms.MultipleChoiceField(
required=False, choices=[("es256", "ES256"), ("rs256", "RS256")]
required=False, choices=[("es256", "ES256"), ("rs256", "RS256"), ("ed25519", "Ed25519")]
)
discoverable_credential = forms.ChoiceField(
required=True,
Expand Down
44 changes: 40 additions & 4 deletions _app/homepage/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import json
from typing import List, Optional
from dataclasses import dataclass, asdict

from pydantic import BaseModel
from webauthn.helpers.structs import AuthenticatorTransport, CredentialDeviceType


class WebAuthnCredential(BaseModel):
@dataclass
class WebAuthnCredential:
"""
A Pydantic class for WebAuthn credentials in Redis. Includes information py_webauthn will need
for verifying authentication attempts after registration.
Expand All @@ -21,5 +23,39 @@ class WebAuthnCredential(BaseModel):
device_type: CredentialDeviceType
backed_up: bool
transports: Optional[List[AuthenticatorTransport]]
# TODO: Clear this at some point point in the future when we know we're setting it
aaguid: str = ""
aaguid: str

@classmethod
def from_json(cls, cred_json: str | dict):
"""
Parse a JSON-ified form of this class into a full-fat class
"""
if type(cred_json) is str:
_json: dict = json.loads(cred_json)
elif type(cred_json) is dict:
_json = cred_json
else:
raise Exception(f"Invalid type {type(cred_json)} for cred_json")

transports_raw = _json.get("transports")
transports_parsed = None
if type(transports_raw) is list:
transports_parsed = [AuthenticatorTransport(val) for val in transports_raw]

return cls(
id=_json["id"],
public_key=_json["public_key"],
username=_json["username"],
sign_count=int(_json["sign_count"]),
is_discoverable_credential=_json.get("is_discoverable_credential"),
device_type=CredentialDeviceType(_json["device_type"]),
backed_up=_json["backed_up"],
transports=transports_parsed,
aaguid=_json["aaguid"],
)

def to_json(self) -> dict:
"""
Convert the model instance to a basic `dict`
"""
return asdict(self)
8 changes: 4 additions & 4 deletions _app/homepage/services/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def retrieve_credential_by_id(
if not raw_credential:
raise InvalidCredentialID("Unrecognized credential ID")

credential = WebAuthnCredential.model_validate_json(raw_credential)
credential = WebAuthnCredential.from_json(raw_credential)

if username and credential.username != username:
raise InvalidCredentialID("Credential does not belong to user")
Expand All @@ -92,7 +92,7 @@ def retrieve_credentials_by_username(self, *, username: str) -> List[WebAuthnCre
all_creds = self.redis.retrieve_all()

credentials = [
WebAuthnCredential.model_validate_json(cred) for cred in all_creds if cred is not None
WebAuthnCredential.from_json(cred) for cred in all_creds if cred is not None
]

return [cred for cred in credentials if cred.username == username]
Expand All @@ -109,7 +109,7 @@ def update_credential_sign_count(self, *, verification: VerifiedAuthentication)
if not raw_credential:
raise InvalidCredentialID()

credential = WebAuthnCredential.model_validate_json(raw_credential)
credential = WebAuthnCredential.from_json(raw_credential)

credential.sign_count = verification.new_sign_count

Expand All @@ -124,6 +124,6 @@ def _temporarily_store_in_redis(self, credential: WebAuthnCredential) -> None:
"""
self.redis.store(
key=credential.id,
value=json.dumps(credential.model_dump()),
value=json.dumps(credential.to_json()),
expiration_seconds=60 * 60 * 24, # 24 hours
)
7 changes: 5 additions & 2 deletions _app/homepage/services/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
class RegistrationService:
redis: RedisService

def __init__(self):
self.redis = RedisService(db=2)
def __init__(self, redis=RedisService(db=2)):
self.redis = redis

def generate_registration_options(
self,
Expand Down Expand Up @@ -101,6 +101,9 @@ def generate_registration_options(
if len(algorithms) > 0:
supported_pub_key_algs = []

if "ed25519" in algorithms:
supported_pub_key_algs.append(COSEAlgorithmIdentifier.EDDSA)

if "es256" in algorithms:
supported_pub_key_algs.append(COSEAlgorithmIdentifier.ECDSA_SHA_256)

Expand Down
27 changes: 24 additions & 3 deletions _app/homepage/templates/homepage/sections/webauthn_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,21 @@
</label>

<section class="col-12 mb-2">
<p class="mb-0">Public Key Algorithms</p>
<p class="mb-0">Supported Public Key Algorithms</p>
<!-- Algorithm - Ed25519 -->
<div class="custom-control custom-checkbox custom-control-inline">
<input
type="checkbox"
name="optAlgEd25519"
id="optAlgEd25519"
class="custom-control-input"
x-model="options.algEd25519"
/>
<label for="optAlgEd25519" class="custom-control-label">
Ed25519
</label>
</div>

<!-- Algorithm - ES256 -->
<div class="custom-control custom-checkbox custom-control-inline">
<input
Expand All @@ -158,7 +172,7 @@
x-model="options.algES256"
/>
<label for="optAlgES256" class="custom-control-label">
Support ES256
ES256
</label>
</div>

Expand All @@ -172,7 +186,7 @@
x-model="options.algRS256"
/>
<label for="optAlgRS256" class="custom-control-label">
Support RS256
RS256
</label>
</div>
</section>
Expand Down Expand Up @@ -337,6 +351,7 @@ <h2>WebAuthn isn't supported. Please consider switching to a modern browser.</h2
*/
this.options.algES256 = currentParams.get('algES256') === 'true';
this.options.algRS256 = currentParams.get('algRS256') === 'true';
this.options.algEd25519 = currentParams.get('algEd25519') === 'true';

const _regUserVerification = currentParams.get('regUserVerification');
for (const uv of this.userVerificationOpts) {
Expand Down Expand Up @@ -433,6 +448,7 @@ <h2>WebAuthn isn't supported. Please consider switching to a modern browser.</h2
regUserVerification: 'preferred',
attestation: 'none',
attachment: 'all',
algEd25519: true,
algES256: true,
algRS256: true,
discoverableCredential: 'preferred',
Expand Down Expand Up @@ -538,6 +554,7 @@ <h2>WebAuthn isn't supported. Please consider switching to a modern browser.</h2
// Submit options
const {
regUserVerification,
algEd25519,
algES256,
algRS256,
attestation,
Expand All @@ -548,6 +565,10 @@ <h2>WebAuthn isn't supported. Please consider switching to a modern browser.</h2

const algorithms = [];

if (algEd25519) {
algorithms.push('ed25519');
}

if (algES256) {
algorithms.push('es256');
}
Expand Down
Empty file added _app/homepage/tests/__init__.py
Empty file.
102 changes: 102 additions & 0 deletions _app/homepage/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import json
from django.test import TestCase

from homepage.models import WebAuthnCredential
from webauthn.helpers.structs import AuthenticatorTransport, CredentialDeviceType

CRED_ID = "m-nPE6tAs2f1eAz2Gd6b9A"
CRED_PUBLIC_KEY = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV1xF0vpUlLsCsuw9Vawaaew9UrgxGTjaRx-y98kKNMMMqNFZJjUzF3xWP6Jqt-n4QWIC_VaPBnvq_zwkc-T0GA"


class TestWebAuthnCredential(TestCase):
def setUp(self):
self.default_cred_dict = {
"id": CRED_ID,
"public_key": CRED_PUBLIC_KEY,
"username": "mmiller",
"sign_count": 0,
"is_discoverable_credential": True,
"device_type": CredentialDeviceType.MULTI_DEVICE.value,
"backed_up": True,
"transports": ["internal", "hybrid"],
"aaguid": "00000000-0000-0000-0000-000000000000",
}

self.default_cred_model = WebAuthnCredential(
id=CRED_ID,
public_key=CRED_PUBLIC_KEY,
username="mmiller",
sign_count=0,
is_discoverable_credential=True,
device_type=CredentialDeviceType.MULTI_DEVICE,
backed_up=True,
transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID],
aaguid="00000000-0000-0000-0000-000000000000",
)

def test_from_json_str(self):
cred = WebAuthnCredential.from_json(json.dumps(self.default_cred_dict))

self.assertEqual(cred.id, CRED_ID)
self.assertEqual(cred.public_key, CRED_PUBLIC_KEY)
self.assertEqual(cred.username, "mmiller")
self.assertEqual(cred.sign_count, 0)
self.assertEqual(cred.is_discoverable_credential, True)
self.assertEqual(cred.device_type, CredentialDeviceType.MULTI_DEVICE)
self.assertEqual(cred.backed_up, True)
self.assertEqual(
cred.transports, [AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID]
)
self.assertEqual(cred.aaguid, "00000000-0000-0000-0000-000000000000")

def test_from_json_dict(self):
cred = WebAuthnCredential.from_json(self.default_cred_dict)

self.assertEqual(cred.id, CRED_ID)
self.assertEqual(cred.public_key, CRED_PUBLIC_KEY)
self.assertEqual(cred.username, "mmiller")
self.assertEqual(cred.sign_count, 0)
self.assertEqual(cred.is_discoverable_credential, True)
self.assertEqual(cred.device_type, CredentialDeviceType.MULTI_DEVICE)
self.assertEqual(cred.backed_up, True)
self.assertEqual(
cred.transports, [AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID]
)
self.assertEqual(cred.aaguid, "00000000-0000-0000-0000-000000000000")

def test_from_json_no_transports(self):
self.default_cred_dict["transports"] = None
cred = WebAuthnCredential.from_json(self.default_cred_dict)

self.assertIsNone(cred.transports)

def test_from_json_unknown_discoverability(self):
self.default_cred_dict["is_discoverable_credential"] = None
cred = WebAuthnCredential.from_json(self.default_cred_dict)

self.assertIsNone(cred.is_discoverable_credential)

def test_to_json(self):
cred_json = self.default_cred_model.to_json()

self.assertEqual(cred_json["id"], CRED_ID)
self.assertEqual(cred_json["public_key"], CRED_PUBLIC_KEY)
self.assertEqual(cred_json["username"], "mmiller")
self.assertEqual(cred_json["sign_count"], 0)
self.assertEqual(cred_json["is_discoverable_credential"], True)
self.assertEqual(cred_json["device_type"], "multi_device")
self.assertEqual(cred_json["backed_up"], True)
self.assertEqual(cred_json["transports"], ["internal", "hybrid"])
self.assertEqual(cred_json["aaguid"], "00000000-0000-0000-0000-000000000000")

def test_to_json_no_transports(self):
self.default_cred_model.transports = None
cred_json = self.default_cred_model.to_json()

self.assertIsNone(cred_json["transports"])

def test_to_json_unknown_discoverability(self):
self.default_cred_model.is_discoverable_credential = None
cred_json = self.default_cred_model.to_json()

self.assertIsNone(cred_json["is_discoverable_credential"])
Loading

0 comments on commit fad5eb3

Please sign in to comment.