By: Richard Ma
If you’re using the U2F API for registration and authentication of your U2F devices, you will notice a dire situation is coming soon: Google Chrome will no longer support U2F API after February 2022:
For anyone using U2F API in their web apps, like us at Sentry, their users who had 2FA enabled with U2F devices would not be able to sign in. To remedy this, there’s a shiny new specification written by the World Wide Web Consortium (W3C) and the Fast IDentity Online Alliance (FIDO) that will solve all our problems.
In this blog post, we will go into the weeds on migrating from U2F API to WebAuthn.
WebAuthn
WebAuthn is an API that allows web services to seamlessly integrate strong authentication into applications. With WebAuthn, web services can offer the user a choice of authenticators, such as security keys (Yubikeys, Titan Keys, for example) or built-in platform authenticators (biometric readers). In addition, it is supported by all the leading browsers — including Safari, which U2F API was not — and web platforms, which standardizes the integration of strong authentication. You can read more on WebAuthn’s website.
Migrating to WebAuthn From U2F API
Now, here’s the part you’re all here for, the migration to WebAuthn. Let’s break this down into two main parts:
Part 1: Authenticating existing U2F and new WebAuthn devices with WebAuthn
- Step 1: Generating the challenge and state
- Step 2: Creating PublicKeyCredential data
- Step 3: Verifying the device
Part 2: Registering new devices with WebAuthn
- Step 1: Generating the PublicKeyCredentialRpEntity and state
- Step 2: Creating PublicKeyCredential data
- Step 3: Registering the device
Part 1: Authentication
Let’s start with authentication so existing users can continue to log in. This is also important because newly registered WebAuthn devices can’t log in without a working WebAuthn login.
To understand the authentication flow, we can look at the following diagrams:
[Source left][12] [Source right][13]
We’ll be using Python for the backend APIs. The U2F API sequence (left) is very similar to the WebAuthn sequence (on the right). We simply have to replace three API calls: u2f.start_authentication()
and u2f.finish_authentication()
in the backend, and u2f.sign()
in the frontend.
Let’s start with u2f.start_authentication()
, which takes in the browser’s application ID and the currently registered devices.
The U2F API authentication process starts with the backend generating a challenge, an example of which is shown below:
{
"appId": "https://your-webauthn-app.io/2fa/u2fappid.json",
"challenge": "VwmGI-4…",
"registeredKeys": [
{
"appId": "https://your-webauthn-app.io/2fa/u2fappid.json",
"keyHandle": "cxSl4oQ…",
"publicKey": "BP4Q8MR…",
"transports": [
"usb"
],
"version": "U2F_V2"
}
]
}
This challenge is sent to the browser where u2f.sign()
takes the challenge as an input and returns a promise that is the result of:
- verifying the application identity of the caller
- creating a client data object and using the client data
- the application ID
- the key handle
This creates a raw authentication request message and sends it to the U2F device. The result of the promise should look like this:
{
"keyHandle": "cxSl4oQ…",
"clientData": "eyJ0eXA…",
"signatureData": "AQAAAQ4…"
}
Once the result of the promise is sent to the server, we call U2f.complete_authentication()
with the following two parameters: the original challenge data and the newly generated client data object passed in. This method will verify the device with the parameters and return the device info if it succeeded. From there, the server can allow the user to pass through the 2FA process.
Step 1: Generating the challenge and state
To start the migration process, let’s first replace u2f.start_authentication()
with its counterpart. The data types that the WebAuthn API takes are not quite the same ones used in U2F API. In fact, one of the main pain points was converting the necessary fields into the correct data type.
We want to authenticate users on legacy U2F API and WebAuthn, so we will create an authentication server first. The following will create an authentication server using WebAuthn that is backwards compatible with U2F API:
webauthn_authentication_server = U2FFido2Server(
app_id=u2f_app_id,
rp={
"id": “sentry-webauthn.io”,
"name": "Sentry with WebAuthn"}
)
Your app_id
will be the same value as before. The rp
, or Relying Party, is an object that contains an ID, which is the hostname of the URL, and the name of your Relying Party.
Next, we need to generate a list of credentials, which is the same as the list of devices for U2F API. Keep in mind that the list of credentials will contain both WebAuthn and U2F API registered devices and that list needs to be manipulated.
credentials = []
for device in self.get_u2f_devices():
if type(device) == AuthenticatorData:
credentials.append(device.credential_data)
else:
credentials.append(create_credential_object(device))
The devices that are registered with WebAuthn have the type AuthenticatorData. For devices registered with U2F API, we need to create an AttestedCredentialData object for them to be compatible with WebAuthn. The following is the function we wrote that decodes the necessary parameters and creates the credential data:
def create_credential_object(registeredKey):
return base.AttestedCredentialData.from_ctap1(
websafe_decode(registeredKey["keyHandle"]),
websafe_decode(registeredKey["publicKey"]),
)
[Source of function][16]
With that, we can begin the registration process by calling register_begin()
on the WebAuthn server that we created earlier, with credentials as its parameter. This will return a challenge and state.
The challenge is needed for the browser to perform authentication, but we will only use the PublicKey object within the challenge. In addition, you should store the state in your sessions, as it will be needed later.
challenge, state = self.webauthn_authentication_server.authenticate_begin(
credentials=credentials
)
request.session["webauthn_authentication_state"] = state
return ActivationChallengeResult(
challenge=cbor.encode(challenge["publicKey"])
)
We also encoded the challenge using the [FIDO2 CBOR][18] library, as we will be sending it to the frontend using JSON, which does not handle binary representation well on its own. On the frontend, we convert the JSON string back into a byte array and decode it to return the challenge to its original form.
Step 2: Creating PublicKeyCredential for authentication
To replace u2f.sign()
, we can call its WebAuthn equivalent navigator.credentials.get()
with the challenge data. This library is now native to modern browsers, so don’t worry about importing any libraries.
const challengeArray = base64urlToBuffer(
challengeData.webAuthnAuthenticationData
);
const challenge = cbor.decodeFirst(challengeArray);
challenge.then(data => {
webAuthnSignIn(data);
}).catch(err => {
const failure = 'DEVICE_ERROR';
Sentry.captureException(err);
this.setState({
deviceFailure: failure,
hasBeenTapped: false,
});
});
function webAuthnSignIn(publicKeyCredentialRequestOptions) {
return navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
}).then(data => {
// Send to backend
})
}
When the promise is resolved after calling navigator.credentials.get()
, we need to send the appropriate data to the backend to finish authentication. To convert the PublicKeyCredential that was obtained from navigator.credentials.get()
, we can run it through the following function:
getU2FResponse(data) {
if (data.response) {
const authenticatorData = {
keyHandle: data.id,
clientData: bufferToBase64url(data.response.clientDataJSON),
signatureData: bufferToBase64url(data.response.signature),
authenticatorData: bufferToBase64url(data.response.authenticatorData),
};
return JSON.stringify(authenticatorData);
}
return JSON.stringify(data);
}
Step 3: Verifying the device
For the final step, we can pass the original challenge and this new response to the backend. We need to create a list of credentials to validate the device, then call authenticate_complete
on the authentication server that was made earlier with the following parameters:
- state: the value which we stored in session from start_authentication
- credentials: list which we just generated
-
A websafe_decode for the following:
- credential_id: a “keyHandle” of the response object
- client_data: a “clientData” of the response object passed through fido2.client.ClientData
- auth_data: an “authenticatorData” of the response object passed through fido2.ctap2.authenticatorData
- signature: a “signatureData” of the response object
self.webauthn_authentication_server.authenticate_complete(
state=request.session["webauthn_authentication_state"],
credentials=credentials,
credential_id=websafe_decode(response["keyHandle"]),
client_data=ClientData(websafe_decode(response["clientData"])),
auth_data=AuthenticatorData(websafe_decode(response["authenticatorData"])),
signature=websafe_decode(response["signatureData"]),
)
If this function returns true, you are now fully authenticated and good to go!
For our deployment, this feature was behind a flag to manage the rollout and for error monitoring. (We recommend using Sentry 😉.) We deployed this feature independently from registration because the area of effect is limited in the event of an incident.
Congrats! The authentication part of the migration is finished.
Part 2: Registration
Similar to authentication, first, let’s take a look at the flow:
[Source left][12] [Source right][20]
The flow of registration is almost identical to that of the authentication process. The three components we need to deprecate in order to migrate to WebAuthn are u2f.begin_registration()
and u2f.complete_registration()
in the backend, and u2f.register()
in the frontend.
Once again, we will start with u2f.begin_registration()
. This API call takes in the u2f application ID and list of registered devices. This results in the following data being sent to the browser to begin the registration process:
{
"appId": "https://your-webauthn-app/2fa/u2fappid.jso",
"registerRequests": [
{
"challenge": "uexgFSl…",
"version": "U2F_V2"
}
],
"registeredKeys": []
}
Similar to u2f.sign()
, u2f.register()
will take the previously generated results and return a promise that will look like the following if the device is registrable:
{
"registrationData": "BQQ1xlC…",
"version": "U2F_V2",
"challenge": "Jkh_Tfo…",
"appId": "https://your-webauthn-app.io/2fa/u2fappid.json",
"clientData": "eyJ0eXA…"
}
Along with the previously generated challenge, this result is sent to the backend where u2f.complete_registration()
takes in both parameters and generates the following data object:
{
"appId": "https://your-webauthn-app.io/2fa/u2fappid.json",
"keyHandle": "SnllNGC…",
"publicKey": "BIs-gsW…",
"transports": [
"usb"
],
"version": "U2F_V2"
}
You can save this dictionary and name of the device. They will be used for authentication.
Just like before, let’s replace u2f.begin_registration()
with its counterpart. We need to create a FIDO2Server and import it from fido2.server
. We don’t need to make it backward compatible, as all new devices will be registered with WebAuthn.
Step 1: Generating the PublicKeyCredentialRpEntity and state
We start with importing the fido2.webauthn
library to create a PublicKeyCredentialRpEntity. To create the entity, we need to pass in the Relying Party’s ID and name. With the entity, we pass it into Fido2Server to set things up.
from fido2.server import Fido2Server
from fido2.webauthn import PublicKeyCredentialRpEntity
rp = PublicKeyCredentialRpEntity(rp_id, "Sentry")
webauthn_registration_server = Fido2Server(rp)
When the server is created, we can optionally pass in a list of registered devices to avoid duplicate registrations.
Next, we call register_begin()
with:
- user: dictionary with the user’s id, name, and display name
- credentials: the list we just generated
- user_verification: normally defaulted to discouraged
You should get a result similar to this:
"publicKey": {
"authenticatorSelection": {
"userVerification": <UserVerificationRequirement.DISCOURAGED: "discouraged">},
"challenge": b"\xe9)#\x86\xfa.\xa9\x82r\x86\xf7\x15e\xb5m\xdc"
b"\x1dR\xc4\x1b\xdb\xab\x94\x88\xb8\x94\xf43"
b"b\x03\xab\n",
"excludeCredentials": [],
"pubKeyCredParams": [
{"alg": -7,
"type": <PublicKeyCredentialType.PUBLIC_KEY: "public-key">},
{"alg": -8,
"type": <PublicKeyCredentialType.PUBLIC_KEY: "public-key">},
{"alg": -37,
"type": <PublicKeyCredentialType.PUBLIC_KEY: "public-key">},
{"alg": -257,
"type": <PublicKeyCredentialType.PUBLIC_KEY: "public-key">}],
"rp": {"id": "<$YOUR_APP>",
"name": "Sentry"},
"user": {"displayName": "<$YOUR_NAME>",
"id": b"\x00",
"name": "<$YOUR_APP>"
}
}
}
The registration data as seen above will be returned. Encode the registration data with the cbor.encode()
method and base64 encode that to a string.
publicKeyCredentialCreate = cbor.encode(registration_data)
return b64encode(publicKeyCredentialCreate)
Set the state for later use in the session.
Interestingly, the data from WebAuthn is exactly the same from U2F API, despite looking different on first glance. WebAuthn sets the challenge in a byte array and the clientData in a COSE key object (which is a CBOR map), while U2F API uses an encoded string.
Step 2: Creating PublicKeyCredential for registration
Once the registration data is received by the browser, we convert the string into a buffer and decode it with this library. This gives us the data that will be used as the input parameter of navigator.credentials.create()
:
challenge, state = self.webauthn_authentication_server.authenticate_begin(
credentials=credentials
)
request.session["webauthn_authentication_state"] = state
return ActivationChallengeResult(challenge=cbor.encode(challenge["publicKey"]))
webAuthnRegister(publicKey) {
const promise = navigator.credentials.create({publicKey});
this.submitU2fResponse(promise);
}
Step 3: Registering the device
We have reached the final step where we need to extract some data from the response from navigator.credentials.create()
. The following are needed for register_complete()
:
-
state: from user sessions, set earlier after
begin_registration()
- client_data: from decoding the data’s cliendDataJSON and creating a ClientData Object with it
- AttestationObject: from decoding the data’s attestationObject and creating an AttestationObject Object with it
data = json.loads(response_data)
client_data = ClientData(
websafe_decode(data["response"]["clientDataJSON"])
)
att_obj = base.AttestationObject(
websafe_decode(data["response"]["attestationObject"])
)
binding = webauthn_registration_server.register_complete(
state, client_data, att_obj
)
ClientData should look like this:
{
"type": "webauthn.create",
"challenge": "_Uas89Y…",
"origin": "https://<$YOUR_APP>",
"crossOrigin": false
}
AttestationObject should look like this:
AttestationObject(
fmt: 'none',
auth_data: AuthenticatorData(
rp_id_hash: h'74cb1ce…5',
flags: 0x41,
counter: 281,
credential_data: AttestedCredentialData(
aaguid: h'0000000…',
credential_id: h'63af2c9…',
public_key: {...}
),
att_statement: {},
ep_attr: None,
large_blob_key: None
)
)
Registered device data:
AuthenticatorData(
rp_id_hash: h'74cb1ce…',
flags: 0x41,
counter: 281,
credential_data: AttestedCredentialData(
aaguid: h'0000000…',
credential_id: h'63af2c9…',
public_key: {...}
)
)
With that, you can save the registered device data. The registration process is complete.
Just like authentication, the deployment of this feature was behind a feature flag to manage the rollout. There were no database migrations needed as WebAuthn is backward compatible with U2F API.
That’s a wrap
With that, WebAuthn should be set up and you can purge U2F API from your codebase. If you have made it this far, we hope that this guide was useful to you. With some planning, you will make it in time before Chrome locks out users from your application. All the best!
—
Everything we do at Sentry is built in the open. Find us on GitHub.