passport-webauthn
passport-webauthn copied to clipboard
Challenge mismatch due to differing packages used for base64 encoding/decoding
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:
- [ ] Update client-side package used at https://github.com/passport/todos-express-webauthn/blob/master/public/js/base64url.js to use https://github.com/niklasvh/base64-arraybuffer instead
- [ ] Instead of using
base64url
, usebase64-arraybuffer
from npm instead - [ ] Instead of using
toBuffer
method in existing instances, simply usedecode
Actually, ignore this, there's a core bug in the package https://github.com/niklasvh/base64-arraybuffer/issues/42
Perhaps instead use https://github.com/mathiasbynens/base64 everywhere (it's browser-compatible too).
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
);
}
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 usedecode
- [ ] On client-side code use
toArrayBuffer
wrapped arounddecode
per comment above https://github.com/jaredhanson/passport-webauthn/issues/7#issuecomment-1854714594.
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)),
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).
Two other issues:
- [ ] This PR needs merged https://github.com/jaredhanson/passport-webauthn/pull/4
- [ ] This also needs a PR https://github.com/jaredhanson/passport-webauthn/issues/5 and merged
Noticed this as well. For us, using the native "Buffer.from" instead of the third party util solved it.
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)
}
};