Deprecation From U2F API to WebAuthn

rahulchhabria

Rahul Chhabria

Posted on January 19, 2022

Deprecation From U2F API to WebAuthn

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:

Image description

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:

Image description

[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"
      }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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…"
}
Enter fullscreen mode Exit fullscreen mode

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"}
)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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"]),
    )
Enter fullscreen mode Exit fullscreen mode

[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"])
)
Enter fullscreen mode Exit fullscreen mode

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
    })
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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"]),
)
Enter fullscreen mode Exit fullscreen mode

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:

Image description

[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": []
}
Enter fullscreen mode Exit fullscreen mode

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…"
} 
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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>"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
) 
Enter fullscreen mode Exit fullscreen mode

ClientData should look like this:

{
    "type": "webauthn.create",
    "challenge": "_Uas89Y…",
    "origin": "https://<$YOUR_APP>",
    "crossOrigin": false
}
Enter fullscreen mode Exit fullscreen mode

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
    )
)
Enter fullscreen mode Exit fullscreen mode

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: {...}
    )
)
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
rahulchhabria
Rahul Chhabria

Posted on January 19, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related