[webauthn] Refine JSON serialization to use UTF-8 encoding for `user.id` and `userHandle` (#2013)

MasterKale has just created a new issue for https://github.com/w3c/webauthn:

== Refine JSON serialization to use UTF-8 encoding for `user.id` and `userHandle` ==
## Proposed Change

The JSON serialization logic we added in L3 consistently uses base64url encoding for any value that's an `ArrayBuffer` in the browser. However, in my experience it makes for easier debugging during authentication when UTF-8 encoding is used specifically for `user.id` and `userHandle` as **it helps developers immediately see the user ID in a recognizable format**.

For example, consider an RP that generates its own string identifiers for users:

```js
const userID = 'USER2ML8P7C08R';
```

An RP dev may **naively** specify this value for `user.id` when calling `parseCreationOptionsFromJSON()` believing, "it's non-PII so why not?"

```js
var opts = PublicKeyCredential.parseCreationOptionsFromJSON({
  // ...
  user: { id: userID, name: 'TestUser', displayName: 'TestUser' },
});

const credential = await navigator.credentials.get({ publicKey: opts });
```

If the RP commits to the spec's JSON serialization methods during a subsequent authentication then they'll experience an unintuitive footgun:

```js
const opts = PublicKeyCredential.parseRequestOptionsFromJSON({ ... });

const credential = await navigator.credentials.get({ publicKey: opts });
const credentialJSON = credential.toJSON();

console.log(userID);                                         // USER2ML8P7C08R
console.log(credentialJSON.response.userHandle)              // USER2ML8P7C08Q
console.log(userID === credentialJSON.response.userHandle);  // false
```

The user ID gets [munged](https://en.wikipedia.org/wiki/Mung_(computer_term))! The `userHandle` becomes unusable, and the dev falls back to using credential ID to determine which user should be logged in (which is a bad idea as @sbweeden re-confirmed recently in https://github.com/w3c/webauthn/issues/1909#issuecomment-1608648459)

In my opinion (gleaned through practical experience) if `userHandle` is the same then it's easy to pull that value out of the front end and cross-reference it when pulling database records, query logging for the value, use and compare the value programmatically in other areas of the product...currently RP devs (that **correctly** base64url-encoded the UTF-8 bytes in `"USER2ML8P7C08R"` as per the current text in L3 and specify `user.id` as `"VVNFUjJNTDhQN0MwOFI"` instead) have to base64url-decode `userHandle` to bytes and then UTF-8 encode those bytes to get back to `"USER2ML8P7C08R"` before continuing with their troubleshooting.

I therefore assert that it is more useful to RP devs for `userHandle` out of a call to `toJSON()` to be the same as the `user.id` string passed into `parseCreationOptionsFromJSON()`. We can remove this footgun by **updating the JSON serialization logic so that the `user.id` argument is allowed to be any UTF-8 string when calling `parseCreationOptionsFromJSON()`, and that UTF-8 encoding is used to serialize `userHandle` when `toJSON()` is called.**

## Potential Impact

While trying to find a browser I could run sample code in to put this all together I noticed that only **Firefox** currently has fully implemented the JSON serialization methods. **Chrome** currently only supports `parseCreationOptionsFromJSON()` and `toJSON()`, while **Safari** doesn't currently support any of the methods.

I'm hoping this means there's still a chance to discuss updating this logic, as it would mean breaking any current use of these methods.

## Footgun Repro

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="startReg">Step 1: Call .create()</button>
    <button id="startAuth">Step 2: Call .get()</button>
    <script>
      /**
       * `user.id` and `userHandle` will not match with this human-readable value
       */
      const userID = "USER2ML8P7C08R";
      /**
       * This `userID` is the base64url-encoded UTF-8 bytes in the string above. `user.id` and
       * `userHandle` will match when this value is used instead.
       */
      // const userID = "VVNFUjJNTDhQN0MwOFI";

      // Values we'll use during auth
      let credIdBase64URL = undefined;
      let credTransports = [];

      document.getElementById("startReg").addEventListener("click", async () => {
        const opts = PublicKeyCredential.parseCreationOptionsFromJSON({
          user: { id: userID, name: "TestUser", displayName: "TestUser"},
          rp: { name: "localhost" },
          challenge: "AAAA",
          pubKeyCredParams: [ { type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
        });

        const credential = await navigator.credentials.create({ publicKey: opts });
        const credentialJSON = credential.toJSON();

        credIdBase64URL = credentialJSON.id;
        credTransports = credentialJSON.response.transports;
      });

      document.getElementById("startAuth").addEventListener("click", async () => {
        const opts = PublicKeyCredential.parseRequestOptionsFromJSON({
          challenge: "AAAA",
          allowCredentials: [{ id: credIdBase64URL, transports: credTransports, type: "public-key" }],
        });

        const credential = await navigator.credentials.get({ publicKey: opts });
        const credentialJSON = credential.toJSON();

        console.log("user.id:", userID);
        console.log("userHandle:", credentialJSON.response.userHandle);
        console.log(userID === credentialJSON.response.userHandle);
      });
    </script>
  </body>
</html>
```

Please view or discuss this issue at https://github.com/w3c/webauthn/issues/2013 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Wednesday, 10 January 2024 05:57:01 UTC