Introduction
The first thing that often comes to mind about FIDO2 WebAuthn clients is their integration with modern web browsers. Typically, users are prompted on a website to verify their identity by entering a PIN and interacting with a physical security key. This user-friendly flow is designed to to be fast, secure, and reduce reliance on passwords.
But what if you need to implement this functionality outside of a browser? What if your application runs in a command-line interface (CLI)? The good news is this is possible, but it’s not well documented or commonly implemented. I was motivated to implement this as leaving a terminal and waiting for a page to load to tap a device seemed wasteful.
This post will guide you through the process of implementing a FIDO2 WebAuthn client in Python. In our example, we wrap https://webauthn.io and cover some of the main concepts along the way. With enough focus this approach could be used to wrap any IDP or service.
Deep dive into WebAuthn
Summary
Mozilla has a detailed explanation here . I will try summarise below.
A typical WebAuthn authentication process in a browser looks like this:
- The “relying party” (the website) sends user and server information to the web app handling the registration process, along with a “challenge”
- The browser asks the authenticator device (e.g. YubiKey) to sign the challenge via a
navigator.credentials.get()
call. This process is called getting an assertion. - If the authenticator contains one of the given credentials and is able to successfully sign the challenge, it returns a signed assertion to the web app after receiving user consent
- The web app forwards the signed assertion to the relying party server to validate
- If everything checks out, the relying party will return a successful response to the web app
(from Trscavo at English Wikipedia available here)
Input
Drilling further into navigator.credentials.get()
, it expects the following fields as parameters/inputs:
challenge
- a value originating from the relying party’s server and used as a cryptographic challengerpId
(optional) - a string that specifies the relying party’s identifier- This is used for identifying which keys to use and for preventing cross-origin attacks
- an example value may be
login.microsoft.com
allowCredentials
(optional) - A restricted the list of acceptable credentials. An empty array indicates that any credential is acceptable.- An empty list is typically used when the relying party is unaware of your username
- For example, GitHub sends an empty allowCredentials list when you press “Sign in with a passkey”.
- If you have multiple credentials registered under the relying party, your browser will ask you to select a credential to authenticate with
timeout
(optional) - a hint indicating the time the relying party is willing to wait for the retrieval operation to completeuserVerification
(optional) - A enumerated string specifying the relying party’s requirements for user verification of the authentication process- e.g. whether or not to do PIN verification
More detail on each option is available:
- In the Mozilla developer documentation
- and in the WebAuthn specification
Most fields are optional, however if specified by the relying party they are important and should not be omitted.
Output
The function then returns an object with type PublicKeyCredential. Typically an identity provider (IDP) will expect this entire object to be sent back, or a subset of the fields under the response
key which has type AuthenticatorAssertionResponse.
The important fields are:
authenticatorData
- contains information about which relying party and credential was used- your device may set an interesting field,
signCount
, a signature counter used to prevent device cloning - if
signCount
is set, then repeated assertions will not return an identicalauthenticatorData
value, thus eachsignature
will be a unique value
- your device may set an interesting field,
clientDataJSON
- contains thechallenge
and the origin used to prevent cross-origin attackssignature
- the combined signature ofauthenticatorData
andclientDataJSON
, signed with the private key of the selected credential associated with the relying party
Once a server receives these values, it can verify the signature was created by the correct credential, and can validate the contents of authenticatorData
and clientDataJSON
.
Inspecting webauthn.io
https://webauthn.io is a public service for testing passkeys. This makes it the perfect test bed for understanding the flow for a WebAuthn client and testing our CLI client. We may create a bunch of malformed requests, so it’s better to do this to a test service and not a real IDP as we may get locked out of our real account.
I won’t be covering registering passkeys in a CLI cause this is only completed once per security key. For the curious, this done in the browser via navigator.credentials.create().
Assuming we have already registered our passkey to a username, we can open the network explorer and observe a dual request log in flow:
- A POST call to
https://webauthn.io/authentication/options
- With a JSON request body:
{"username":"jfxac","user_verification":"preferred"}
- And a JSON response body:
{"challenge": "BASE64URL_CHALLENGE", "timeout": 60000, "rpId": "webauthn.io", "allowCredentials": [{"id": "BASE64URL_ID", "type": "public-key", "transports": ["usb"]}], "userVerification": "preferred"}
- Luckily these are the exact key value pairs used by
navigator.credentials.get()
- Luckily these are the exact key value pairs used by
- With a JSON request body:
- A POST call to
https://webauthn.io/authentication/verification
- With a JSON request body: containing the full response of
navigator.credentials.get()
- And a JSON response body:
{"verified": true}
- With a JSON request body: containing the full response of
With all the information required, we can write some code to call the APIs and feed the values into a WebAuthn client.
Writing some code
Yubico have a created a Python package called fido2 which has a WebAuthn client. They also provided an example which acts as both a server & client and allows for registering a credential and getting an assertion. We will use this code as a base for our webauthn.io wrapper.
The psuedocode is roughly:
- Initialise a FIDO2 WebAuthn client
- Call https://webauthn.io/authentication/options with username provided by command-line arguments
- Pass the assertion options to the client, and wait for the user to complete the perform the actions
- Once complete, call https://webauthn.io/authentication/verification with the values returned by the WebAuthn client
- If successful, write something to the terminal and exit successfully - - otherwise write any errors to the terminal and exit unsuccessfully
A POST request to webauthn.io to kick off a log in:
import requests
COOKIES = {
'csrftoken': generate_random_string(32),
'sessionid': generate_random_string(32),
}
def get_options(username):
data = {
'username': username,
'user_verification': 'required',
}
response = requests.post('https://webauthn.io/authentication/options', cookies=COOKIES, json=data)
return response.json()
print(get_options(username))
This will give our challenge
, rpId
, allowCredentials
, and userVerification
values.
We can then call our WebAuthn client, passing in the above key value pairs:
I’ve removed the client initialisation code for this snippet but it’s available here: https://github.com/Yubico/python-fido2/blob/main/examples/exampleutils.py#L70
from fido2.utils import websafe_decode
...
...
options = get_options()
client = get_client()
request_options = {
'rpId': options['rpId'],
'challenge': websafe_decode(options['challenge']),
'timeout': 600000,
'allowCredentials': [PublicKeyCredentialDescriptor(type=cred['type'], id=websafe_decode(cred['id']), transports=cred['transports']) for cred in options['allowCredentials']],
'userVerification': options['userVerification'],
}
result = client.get_assertion(request_options)
result = result.get_response(0)
result
will contain our signed assertion, which can be sent to webauthn.io
Note the websafe_decode
method used to give the FIDO2 library the raw challenge. A typical mistake is to give the WebAuthn client the challenge incorrectly encoded. In that case, the client will still sign an assertion, but the IDP will return a failure as the challenge is incorrect.
With the assertion from our device, we can make a POST request to webauthn.io with the fields populated:
from fido2.utils import websafe_decode
import requests
def respond(username, result):
data = {
'username': username,
'response': {
'authenticatorAttachment': 'cross-platform',
'clientExtensionResults': {},
'id': websafe_encode(result['credentialId']),
'rawId': websafe_encode(result['credentialId']),
'type': 'public-key',
'response': {
'clientDataJSON': websafe_encode(result['clientDataJSON']),
'authenticatorData': websafe_encode(result['authenticatorData']),
'signature': websafe_encode(result['signature']),
'userHandle': websafe_encode(result['userHandle']),
},
},
}
response = requests.post('https://webauthn.io/authentication/verification', cookies=COOKIES, json=data)
return response.json()
Note using websafe_encode
to encode many of the fields. The data needs to be encoded as it’s in raw bytes. Typically Base64 URL encoded strings are used, but some IDPs may encode values differently. Make sure to identify the correct encoding.
Piecing it all together
With all the pieces found we can write a complete CLI tool. Here is a demo:
And here is the complete code that I published as a gist
Most of the code is initialising the FIDO2 WebAuthn client based on the example code mentioned earlier. The complexity lies in signing the assertion correctly and creating web requests exactly the way webauthn.io expects. This includes cookies and headers. It may take a bit of trial and error to get everything perfect.
Handling WSL
The following works great on Linux, Mac, and on native Windows, but does not work on WSL. Windows does not pass-through USB devices to WSL natively.
There are a few solutions however:
- Install Python +
fido2
package on Windows, callpython.exe
and capture the assertion (e.g. viastdout
) - Follow this guide by Microsoft to expose the USB device to WSL
- With enough elbow grease you could automate this process
Conclusion
I enjoy working with hardware keys and have a keen interest in how FIDO2 works, so this was a really fun post to write. If you don’t write a CLI application with WebAuthn, I hope you still learnt something about how it works.
See also
- Adam Langley’s book “A Tour of WebAuthn”
- I wish existed when I first learnt about WebAuthn
- The WebAuthn spec
- https://en.wikipedia.org/wiki/WebAuthn
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API
Thank you for reading
Thank you so much for reading my post. If you have any feedback or queries, please reach out to me. My details are on the home page.