webauthnfido-u2f

Unable to use AppId extension with WebAuthn for previously registered U2F keys


With the eminent demise of the u2f api, I'm trying to move to WebAuthn APIs using the AppId extension to support security keys previously registered with U2F. As best I can tell from reading the docs I think I am doing it correctly, however, when attempting to authenticate I am prompted by my browser to tap my key, and my key is blinking, but upon tapping it I get the error "You're using a security key that's not registered with this website". In comparing the existing u2f authentication request I'm using the same appid and key handle.

Example U2F sign request:

{
  "version": "U2F_V2",
  "challenge": "zSeDYPUjDVbLQ9HDle3g2QYrHEdG5vGBwAhzqdm_PAY",
  "appId": "https:\/\/subdomain.domain.net\/app-id.json",
  "keyHandle": "H-tBDjS1Fgr19cprKYUnZ9cDSE2AiX_Ld1kdPR2ruhIUbYr7jP3dflxkjZmfvqxkg5q84eXBr3ium3ETJ61Fww"
}

And here is my server response that is handed to the webauthn js library:

{
  "publicKey": {
    "challenge": "iygioh7vECe9OCQ5K0IBa0XeTD5hxX+aOBGimrJAntg=",
    "timeout": 60000,
    "rpId": "domain.net",
    "allowCredentials": [
      {
        "type": "public-key",
        "id": "SC10QkRqUzFGZ3IxOWNwcktZVW5aOWNEU0UyQWlYX0xkMWtkUFIycnVoSVViWXI3alAzZGZseGtqWm1mdnF4a2c1cTg0ZVhCcjNpdW0zRVRKNjFGd3c="
      }
    ],
    "userVerification": "discouraged",
    "extensions": {
      "appid": "https://subdomain.domain.net/app-id.json"
    }
  }
}

The base64 url decoded version of that credential id is

H-tBDjS1Fgr19cprKYUnZ9cDSE2AiX_Ld1kdPR2ruhIUbYr7jP3dflxkjZmfvqxkg5q84eXBr3ium3ETJ61Fww

I noticed in our U2F version that the appid url is escaped for some reason, so I've tried the same thing in WebAuthn version but that did not make a difference.

I've also tried the challenge and credential id without the trailing padding (=), but that also did not help.

The JS client we're using to interact with the webauthn API appears to be decoding the challenge and credential id into binary array buffers. Here is a console log of the object sent as the publicKey to the navigator.credential.get API:

{
  "challenge": { "0": 83, "1": 195, "2": 81, "3": 9, "4": 32, "5": 53, "6": 59, "7": 244, "8": 34, "9": 113, "10": 189, "11": 177, "12": 61, "13": 184, "14": 170, "15": 86, "16": 43, "17": 206, "18": 102, "19": 145, "20": 218, "21": 136, "22": 137, "23": 18, "24": 14, "25": 176, "26": 210, "27": 54, "28": 201, "29": 57, "30": 156, "31": 21},
  "timeout": 60000,
  "rpId": "domain.net",
  "allowCredentials": [
    {
      "type": "public-key",
      "id": { "0": 72, "1": 45, "2": 116, "3": 66, "4": 68, "5": 106, "6": 83, "7": 49, "8": 70, "9": 103, "10": 114, "11": 49, "12": 57, "13": 99, "14": 112, "15": 114, "16": 75, "17": 89, "18": 85, "19": 110, "20": 90, "21": 57, "22": 99, "23": 68, "24": 83, "25": 69, "26": 50, "27": 65, "28": 105, "29": 88, "30": 95, "31": 76, "32": 100, "33": 49, "34": 107, "35": 100, "36": 80, "37": 82, "38": 50, "39": 114, "40": 117, "41": 104, "42": 73, "43": 85, "44": 98, "45": 89, "46": 114, "47": 55, "48": 106, "49": 80, "50": 51, "51": 100, "52": 102, "53": 108, "54": 120, "55": 107, "56": 106, "57": 90, "58": 109, "59": 102, "60": 118, "61": 113, "62": 120, "63": 107, "64": 103, "65": 53, "66": 113, "67": 56, "68": 52, "69": 101, "70": 88, "71": 66, "72": 114, "73": 51, "74": 105, "75": 117, "76": 109, "77": 51, "78": 69, "79": 84, "80": 74, "81": 54, "82": 49, "83": 70, "84": 119, "85": 119}
    }
  ],
  "userVerification": "discouraged",
  "extensions": {
    "appid": "https://subdomain.domain.net/app-id.json"
  }
}

What am I doing wrong? We have thousands of users using yubikeys with u2f and with such a short time frame for a migration we really need this backwards compatibility option to work.

Solution: @IAmKale was right that this was a double encoding issue, or perhaps it was a triple encoding issue. Anyway, the solution was the H-tBD... string needed to be raw url base64 decoded before sending back to browser. Thank you @IAmKale for the suggestions that led to figuring this out!


Solution

  • Everything about the options you pass to navigator.credentials.get() looks correct, including how you're specifying the "appid" extension. I believe the issue is that you're double-encoding your U2F credential's credential ID. Try passing the original "keyHandle" in the options instead (you can use it as-is because it's already compatible with base64url encoding):

    {
      "publicKey": {
        "challenge": "iygioh7vECe9OCQ5K0IBa0XeTD5hxX+aOBGimrJAntg=",
        "timeout": 60000,
        "rpId": "domain.net",
        "allowCredentials": [
          {
            "type": "public-key",
            "id": "H-tBDjS1Fgr19cprKYUnZ9cDSE2AiX_Ld1kdPR2ruhIUbYr7jP3dflxkjZmfvqxkg5q84eXBr3ium3ETJ61Fww"
          }
        ],
        "userVerification": "discouraged",
        "extensions": {
          "appid": "https://subdomain.domain.net/app-id.json"
        }
      }
    }
    

    I'm almost certain that's all you need to do to get WebAuthn to work with your old U2F credentials. If that still doesn't work, try it again with "appid" set to the escaped "appId" URL from the U2F sign request you posted.