Migrating a U2F auth flow to WebAuthn


NOTE: This guide assumes a moderate level of comfort with U2F and WebAuthn concepts and jargon, and a minimal understanding of public-key signing. Implementing the algorithm to migrate your authentication code doesn’t require that you understand these, but all keywords in the explanation that leads to that algorithm may not be adequately explained for a user without some understanding of the WebAuthn protocol.

FIDO and U2F Authentication

If you (or your company) were early on the FIDO key-based user authentication, you’ve got an implementation of U2F somewhere in the authentication service/portion of your code base. The U2F API is scheduled to be turned off by default in Chromium in Feburary 2022, and deleted in March 2022.

In this walkthrough I’ll take you through some of the non-obvious work your application will need to make sure that your authentication keeps working for early adopters in early 2022. Migrating to WebAuthn will have the additional benefit of allowing credential based authentication for your users on mobile, Safari, or with Trusted Platform Modules (TPM) like Windows Hello.

WebAuthn Assertions for user authentication

When using WebAuthn in a WebAuthn Client (typically a browser) the standard is completely backwards compatible with U2F. This helps reduce the amount of work necessary to continue supporting legacy U2F device registrations. Your authentication service won’t have to disrupt any of your users' ability to log in when you’ve implemented it correctly. U2F Sign Requests differ from WebAuthn Assertion requests in form, but they are familiar. The most important portions of the PublicKeyCredentialRequestOptions object for backwards compatibility are the allowedCredentials and extensions fields.

{
    allowedCredentials: [
        type: "public-key",
        id: "q3X8YW_NjEQVSvTGFr1wJT0FnTB7cjVEDC_95_Kpc7P8bo8kpoRcC8RQ4QiuUz1CckQE4wtLByBb34uurnBQbA=="
    ],
    rpId: "jacobcasper.com",
    timeout: 30000,
    challenge: "5SHNCpHeHfkHjNkO1H767BOBCssMgrvh8s0P5s2VWXcvoeaCwJ4gBG8Dr3K2n3Yy2gVz7hdZWfUVZ3taqpc2JQ==",
    userVerification: "preferred",
    extensions: {
        appid: "https://www.jacobcasper.com/u2f_appId.json"
    }
}

The appid extension allows you to send the U2F appId that you were using with U2F Sign Requests, but it requires that you give the Client additional information. You’ll need to also send the allowedCredentials array populated with the U2F keyHandle. This is what you stored from the user’s original U2F registration. With extensions.appid and allowedCredentials populated in your request, you’ll be able to use this request in any WebAuthn Client (navigator.credentials.get in a browser) and have it passed along to a FIDO2 compliant Authenticator and signed properly.

Server side WebAuthn validation with U2F registered devices

Now, your authentication service needs to validate the WebAuthn AuthenticatorAssertionResponse, specifically, cryptographically verifying the signature field. There are some very small hitches that come up here in converting what you know about your users' U2F devices to WebAuthn credentials for most common libraries.

Credential IDs

Credential IDs are the easiest concept to transfer. They’re the U2F device keyHandle that you should have stored during U2F registration.

User Handles

User Handles are decided by the Relying Party (RP) which in this case is your authentication service. In WebAuthn/FIDO2, they are decided by the RP during registration, and must be a pseudo-random number generator (PRNG) generated 32 to 64-byte sequence that uniquely identifies a user. They should not contain information about the user they identify, such as being the first 64 bytes of a username.

Unfortunately, U2F had no concept of a user handle, so they are always null when the server receives them. This means that your authentication service will need to backfill user handles for all existing users / U2F device registrations, however they are stored in your system.

This is relatively simple, and can be as little as a Postgres bytea column filled via a cryptographically secure PRNG.

// Java
random = new SecureRandom();
final var userHandle = new byte[64];
random.nextBytes(userHandle);

# Python3
userHandle = os.urandom(64)

# *nix
# This is how I generated those challenges above and IDs in examples above!
# Base64 decode or store in a varchar column
$ dd if=/dev/urandom count=1 bs=64 | base64 | tr -d '\n'
alT9NM96w9n1UwTGQ7vVg_K8zpUO7uNaqhgI3W_LLxNgvuUui5uslNacCCF1FAQGEjtuMmEGo4LRTCH1Wwr47g==

You should make sure to populate these for any new WebAuthn registrations as well, it’s much easier to only backfill during the initial stages of the migration.

Public keys and COSE_Key Objects

This is unfortunately the most involved part of the migration, but luckily I’ve done the research so that you don’t have to.

WebAuthn has more options for public key generation than U2F did, as it aims to cover more use cases than merely browser clients. Due to those goals, public keys are represented as COSE_Key objects. COSE_Keys are CBOR maps, which are similar to JSON Objects. We’ll determine the values that need to be hardcoded in to the following map by reading more of the U2F spec.

{
  1:  int, //kty, Key type
  3:  int, //alg, Signing algorithm
  -1: int, //crv, Elliptic Curve identifier
  -2: int, //x, x-coordinate
  -3: int, //y, y-coordinate
}

When your users registered a U2F device, the registration response contained their public key, which is how your authentication service has been validating the signatures on Sign Requests until now. If you look at the raw message format section of the specification, you’ll see that the “user public key” is a:

[65 byte] uncompressed x,y-representation of a curve point on the P-256 NIST elliptic curve.

If you don’t know what that looks like, here’s how to generate a valid private/public key pair on the P-256 NIST curve.

$ openssl ecparam -name secp256r1 -genkey | openssl ec -pubout -conv_form uncompressed -text

...
pub:
    04:d3:8b:20:16:59:24:5c:b0:18:f3:56:f2:37:03:
    99:54:08:45:d6:a9:e7:c3:6e:d1:ab:98:d7:8d:7a:
    e6:32:35:3d:ee:8b:5d:73:9e:dc:3a:b7:b1:d0:cf:
    a0:55:79:48:fa:4d:52:f9:4c:09:a8:68:07:b9:d2:
    86:91:d5:5d:33
...

We’ll use this key in our examples. This includes a 32-byte x-coordinate and a 32-byte y-coordinate, but you may notice we have an extra byte then.

If you look at RFC 5480 Section 2.2, you’ll see that the 65-byte uncompressed representation of an Elliptic Curve Public Key is:

The first octet of the OCTET STRING … [I]s indicated by 0x04.

Which means our public key can be more clearly represented as:

04

x:
  d3 8b 20 16 59 24 5c b0
  18 f3 56 f2 37 03 99 54 
  08 45 d6 a9 e7 c3 6e d1 
  ab 98 d7 8d 7a e6 32 35
y:
  3d ee 8b 5d 73 9e dc 3a 
  b7 b1 d0 cf a0 55 79 48 
  fa 4d 52 f9 4c 09 a8 68 
  07 b9 d2 86 91 d5 5d 33

Armed with this knowledge, we can continue reading RFC 8152 Section 13 to figure out how to represent our U2F user’s public keys in CBOR.

We know:

Therefor our example COSE_Key object looks like this:

{
    1: 2,
    3: -7,
    -1: 1,
    -2: h'd38b201659245cb018f356f2370399540845d6a9e7c36ed1ab98d78d7ae63235',
    -3: h'3dee8b5d739edc3ab7b1d0cfa0557948fa4d52f94c09a86807b9d28691d55d33'
}

And your library will probably want it as a CBOR-encoded byte array. You can check http://cbor.me to see the byte array representation.

You’ll notice the only thing that differs per-object is our public key’s two 32-byte values, so if you want to bypass your language’s CBOR library, you can just splice x bytes in to the 10th-41st indices, and the y bytes in to 45th-76th indices.

A5                                      # map(5)
   01                                   # unsigned(1)
   02                                   # unsigned(2)
   03                                   # unsigned(3)
   26                                   # negative(6)
   20                                   # negative(0)
   01                                   # unsigned(1)
   20                                   # negative(0)
   58 20                                # bytes(32)
      D38B201659245CB018F356F2370399540845D6A9E7C36ED1AB98D78D7AE63235 # "\xD3\x8B \x16Y$\\\xB0\x18\xF3V\xF27\x03\x99T\bE\xD6\xA9\xE7\xC3n\xD1\xAB\x98\xD7\x8Dz\xE625"
   22                                   # negative(2)
   58 20                                # bytes(32)
      3DEE8B5D739EDC3AB7B1D0CFA0557948FA4D52F94C09A86807B9D28691D55D33 # "=\xEE\x8B]s\x9E\xDC:\xB7\xB1\xD0\xCF\xA0UyH\xFAMR\xF9L\t\xA8h\a\xB9\xD2\x86\x91\xD5]3"

// Java, MIT Licensed
static byte[] ecRawKeyToCoseKeyCBOR(final byte[] pubkey) {
    final var cbor = new byte[77];
    cbor[0] = (byte)0xA5;
    cbor[1] = 0x01;
    cbor[2] = 0x02;
    cbor[3] = 0x03;
    cbor[4] = 0x26;
    cbor[5] = 0x20;
    cbor[6] = 0x01;
    cbor[7] = 0x21;
    cbor[8] = 0x58;
    cbor[9] = 0x20;
    cbor[42] = 0x22;
    cbor[43] = 0x58;
    cbor[44] = 0x20;
    // x
    for(int i = 0; i < 32; i++) {
        cbor[i + 10] = pubkey[1 + i];
    }
    // y
    for(int i = 0; i < 32; i++) {
        cbor[i + 45] = pubkey[33 + i];
    }
    return cbor;
}

Java impl

Finale

With backfilled user handles and your COSE_Key objects / byte arrays, you should be able to pass your AuthenticatorAssertionResponses in to whichever WebAuthn library you’ve implemented in your codebase and have them be cryptographically verified successfully.

Acknowledgements

I’d like to thank Emil Lundberg (Yubico) for being a helpful resource and introducing me to http://cbor.me. They’re the maintainer of the com.yubico.webauthn-server-core Java library and were willing to answer my feverish questions about the library.