Authors: Vitor Cunha, Pedro Escaleira
Slides
Download here
Introduction
FIDO2 is a modern authentication standard aimed at improving online security by eliminating the reliance on passwords and providing more robust and straightforward authentication methods. Created by the FIDO (Fast IDentity Online) Alliance, FIDO2 is part of a collective initiative to replace traditional password-based logins with more secure alternatives, such as passwordless or multifactor authentication (MFA).
FIDO2 is an umbrella term for two main technical components: (1) The WebAuthn (Web Authentication API), developed by the World Wide Web Consortium (W3C), allows browsers and applications to authenticate users via public-key cryptography. With WebAuthn, online services can register and authenticate users using their public keys without ever needing to store passwords. (2) The CTAP (Client to Authenticator Protocol) allows external devices, such as USB keys, mobile devices, or biometric devices (like fingerprint readers), to communicate securely with browsers and applications for authentication purposes.
In this guide we will explore the passwordless login features that WebAuthn provides.
Setup
In order to experiment with FIDO2 and WebAuthn we need both a client side (i.e., authenticator token) and a server-side (i.e., authenticating application). We will begin by setting up the environment.
Client-side
Use the KeePassXC for the client-side. Later, you may also use Windows Hello, Android, Apple Keychain, Yubikey, or other commercially-graded solutions.
Install KeepassXC (version 2.7.7 or higher) in the VM:
sudo add-apt-repository ppa:phoerious/keepassxc
sudo apt install keepassxc
Launch KeepassXC and create a new database. Now, go to Tools –> Settings, and enable the browser integration for Firefox. Within the browser integration settings, go to Advanced and enable “Allow using localhost with passkeys.”
Launch Firefox and install the KeePassXC-Browser plugin. Connect the plugin to your running KeePassXC, giving a name to your browser connection.
Go to the KeePassXC-Browser settings and tick the “Enable passkeys” option.
Now, test the passkey functionality using the yubico test website. You must first enroll your password manager like a new user was created. Only then can you authenticate your user using the passkey functionality.
You should reach the authentication successful webpage before moving forward in this step.
Server-side
Now that we have a functioning authenticating token (i.e., the password manager) we will explore this protocol within a webserver application. To implement the server-side, when you already have a web application and want to add FIDO2 functionality, we recommend using the py_webauthn module by Cisco Duo Labs.
For this guide, we will use the py_webauthn
module creators demonstration, with a few crucial methods removed so that you can learn how that process works.
# Lets clone the remote repository
$ git clone https://github.com/vitorcunha/sio_fido2-webathn.git fido2-demo
# enter the folder for the server demo code
$ cd fido2-demo
# Create your virtualenv
$ virtualenv venv
# Enter the virtualenv venv
$ source venv/bin/activate
# Install the requirements and their dependencies
$ sudo apt install libffi-dev
$ pip install -r requirements.txt
# Run the webapp
$ ./start-server.sh
If you follow http://localhost:5000/ you should be greeted with the following page:
WARNING: The Web app will not work correctly at this stage!
Exploring the web app
The most important methods (now missing) should be implemented in src/app.py
. Please open that file in your favorite editor and locate the missing code.
Registration works in two steps: first, you need to create the credential, and then you need to validate the response from the client.
General considerations
Even though the default Flask configuration should work as-is, when creating your web app beware of the relevant Flask configurations.
The py_webauthn module exposes a few crucial methods, which we will explore in the following subsections:
generate_registration_options()
verify_registration_response()
generate_authentication_options()
verify_authentication_response()
Make sure to read the documentation here for more information.
Registration
Create credential
In broad terms, the process works as follows:
- Client retrieves publickKeyCredentialCreationOptions (pkcco) from server; state/challenge must be preserved on the server side.
- Client/navigator calls authenticator with options to create credential.
- Authenticator will create new credential and return an attestation response (new credential’s public key + metadata).
Example from the documentation.
Please implement the credential creation method in line 81:
@app.route("/generate-registration-options", methods=["GET"])
def handler_generate_registration_options():
global current_registration_challenge
global logged_in_user_id
user = in_memory_db[logged_in_user_id]
options = generate_registration_options(
rp_id=rp_id,
rp_name=rp_name,
user_id=user.id,
user_name=user.username,
exclude_credentials=[
{"id": cred.id, "transports": cred.transports, "type": "public-key"}
for cred in user.credentials
],
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
],
)
current_registration_challenge = options.challenge
return options_to_json(options)
Register credential
In broad terms, the credential registration process works as follows:
- Attestation is packed; credential object is RO, ArrayBuffers must be casted to views (Uint8Array) before CBOR encoding.
- Packed attestation is sent to the server for registration.
- Server verifies the attestation and stores credential public key and association with the user.
Example from the documentation.
Please implement the verification method in line 111: (the line number may change depending on how you wrote the previous code)
@app.route("/verify-registration-response", methods=["POST"])
def handler_verify_registration_response():
global current_registration_challenge
global logged_in_user_id
body = request.get_data()
try:
credential = RegistrationCredential.parse_raw(body)
verification = verify_registration_response(
credential=credential,
expected_challenge=current_registration_challenge,
expected_rp_id=rp_id,
expected_origin=origin,
)
except Exception as err:
return {"verified": False, "msg": str(err), "status": 400}
user = in_memory_db[logged_in_user_id]
new_credential = Credential(
id=verification.credential_id,
public_key=verification.credential_public_key,
sign_count=verification.sign_count,
transports=json.loads(body).get("transports", []),
)
user.credentials.append(new_credential)
return {"verified": True}
Authentication
Create assertion
In broad terms, the process works as follows:
- Client retrieves publicKeyCredentialRequestOption (pkcro) from server; state/challenge has to be preserved on the server side.
- Client/navigator calls authenticator with options to generate assertion.
Example from the documentation.
You will find the assertion creation method in line 150: (the line number may change depending on how you wrote the previous code)
@app.route("/generate-authentication-options", methods=["GET"])
def handler_generate_authentication_options():
global current_authentication_challenge
global logged_in_user_id
user = in_memory_db[logged_in_user_id]
options = generate_authentication_options(
rp_id=rp_id,
allow_credentials=[
{"type": "public-key", "id": cred.id, "transports": cred.transports}
for cred in user.credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
current_authentication_challenge = options.challenge
return options_to_json(options)
Authenticate (using) assertion
In broad terms, the final authentication process works as follows:
- Assertion is packed; credential is RO, ArrayBuffers must be casted to views (Uint8Array) before CBOR encoding.
- Packed assertion is sent to the server for authentication.
- Server validates the assertion (challenge, signature) against registered user credentials and performs logon process on success.
Example from the documentation.
You will find the assertion verification method in line 171: (the line number may change depending on how you wrote the previous code)
@app.route("/verify-authentication-response", methods=["POST"])
def hander_verify_authentication_response():
global current_authentication_challenge
global logged_in_user_id
body = request.get_data()
try:
credential = AuthenticationCredential.parse_raw(body)
# Find the user's corresponding public key
user = in_memory_db[logged_in_user_id]
user_credential = None
for _cred in user.credentials:
if _cred.id == credential.raw_id:
user_credential = _cred
if user_credential is None:
raise Exception("Could not find corresponding public key in DB")
# Verify the assertion
verification = verify_authentication_response(
credential=credential,
expected_challenge=current_authentication_challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=user_credential.public_key,
credential_current_sign_count=user_credential.sign_count,
require_user_verification=True,
)
except Exception as err:
return {"verified": False, "msg": str(err), "status": 400}
# Update our credential's sign count to what the authenticator says it is now
user_credential.sign_count = verification.new_sign_count
return {"verified": True}
Extra
Try the server code with a different client such as Windows Hello, a FIDO2 USB token such as Yubikey, Nitrokey FIDO2, or Thetis. If your server is exposed to the local network, you may also attempt using your Android phone or Apple Keychain.
Bibliography
https://www.w3.org/TR/webauthn-2/
https://fidoalliance.org/fido2/
Tutorial + Code: https://duo.com/blog/going-passwordless-with-py-webauthn