passport-webauthn icon indicating copy to clipboard operation
passport-webauthn copied to clipboard

Challenge mismatch due to differing packages used for base64 encoding/decoding

Open titanism opened this issue 1 year ago • 8 comments

Hi there - this project uses base64url on server-side and on client-side the demo uses base64-arraybuffer (albeit an outdated version). A new version of base64-arraybuffer is at https://github.com/niklasvh/base64-arraybuffer.

We recommend a few things:

titanism avatar Dec 13 '23 20:12 titanism

Actually, ignore this, there's a core bug in the package https://github.com/niklasvh/base64-arraybuffer/issues/42

titanism avatar Dec 13 '23 20:12 titanism

Perhaps instead use https://github.com/mathiasbynens/base64 everywhere (it's browser-compatible too).

titanism avatar Dec 13 '23 20:12 titanism

Note that on the client-side, you can use this for converting from base64.decode to ArrayBuffer as required by navigator.credentials.create and navigator.credentials.get:

// <https://gist.github.com/miguelmota/5b06ae5698877322d0ca?permalink_comment_id=3611597#gistcomment-3611597>
// <https://stackoverflow.com/a/31394257>
function toArrayBuffer(buffer) {
  return buffer.buffer.slice(
    buffer.byteOffset,
    buffer.byteOffset + buffer.byteLength
  );
}

titanism avatar Dec 13 '23 21:12 titanism

Re-opening with a little updated recommendation list (instead of original):

  • [ ] Update client-side package used at https://github.com/passport/todos-express-webauthn/blob/master/public/js/base64url.js to use https://github.com/brianloveswords/base64url
  • [ ] Instead of using toBuffer method in existing instances, simply use decode
  • [ ] On client-side code use toArrayBuffer wrapped around decode per comment above https://github.com/jaredhanson/passport-webauthn/issues/7#issuecomment-1854714594.

titanism avatar Dec 13 '23 21:12 titanism

This is what we mean by use toArrayBuffer on client-side above:

const credential = await navigator.credentials.create({
  publicKey: {
    rp: {
      name: 'SOME NAME'
    },
    user: {
      id: toArrayBuffer(base64url.toBuffer(window.USER.id)),
      // <https://blog.millerti.me/2023/02/14/controlling-the-name-displayed-during-webauthn-registration-and-authentication/>
      name: window.USER.email,
      displayName: window.USER.email
    },
    // https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/client_data_json.md
    challenge: toArrayBuffer(base64url.toBuffer(response.body.challenge)),

titanism avatar Dec 13 '23 21:12 titanism

Thank you @jaredhanson for all your work here on this project and package. We're implementing it on https://forwardemail.net. Reviewed a lot of the TODO's in the codebase. Happy to help maintain the project and npm releases. We also maintain @koajs, @expressjs, @ladjs, @breejs, @cabinjs, and more. If interested, just grant npm and GitHub access to the "titanism" user, and our team at @forwardemail will help maintain (we use np for releases and changelogs).

titanism avatar Dec 13 '23 21:12 titanism

Two other issues:

titanism avatar Dec 14 '23 01:12 titanism

Noticed this as well. For us, using the native "Buffer.from" instead of the third party util solved it.

hansemannn avatar May 12 '24 17:05 hansemannn

Could this be because the custom library being used (base64url) is encoding strings without the base64 padding?

btoa('User:10') // 'VXNlcjoxMA=='
Buffer.from('User:10').toString('base64') // 'VXNlcjoxMA=='
base64url.encode('User:10') // 'VXNlcjoxMA'

I'm only able to get the challenge to be accepted by removing the padding:

Buffer.from('User:10').toString('base64').replaceAll('=', '');

If you decode the ArrayBuffers processed by this lib:

new TextDecoder('utf-8').decode(base64url.decode('VXNlcjoxMA')) // 'User:10'
new TextDecoder('utf-8').decode(base64url.decode('VXNlcjoxMA==')) // 'User:10\x00\x00'

If I instead use atob to create the arraybuffer:

function base64ToArrayBuffer(base64) {
    const binaryString = atob(base64);
    const len = binaryString.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) {
        bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
}

and then I use TextDecoder to convert those from ArrayBuffers, I see a more consistent result (because it respects the paddings):

new TextDecoder('utf-8').decode(base64ToArrayBuffer('VXNlcjoxMA')) // 'User:10'
new TextDecoder('utf-8').decode(base64ToArrayBuffer('VXNlcjoxMA==')) // 'User:10'

So it seems you can use Buffer.from(str).toString('base64') on the server side, and then you just need to make sure you account for the paddings on the client side as well when doing navigator.credentials.create.

With these 2x helper functions in the browser for ArrayBuffer I'm getting it consistently working:

function base64ToArrayBuffer(base64) {
    const binaryString = atob(base64);
    const len = binaryString.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) {
        bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
}

function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary);
}
return navigator.credentials.create({
  publicKey: {
    rp: {
      name: 'Todos'
    },
    user: {
      id: base64ToArrayBuffer(json.user.id),
      name: json.user.name,
      displayName: json.user.displayName
    },
    challenge: base64ToArrayBuffer(json.challenge),

and later:

.then(function(credential) {
  var body = {
    response: {
      clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
      attestationObject: arrayBufferToBase64(credential.response.attestationObject)
    }
  };

intellix avatar Oct 29 '24 13:10 intellix