WebAuthn icon indicating copy to clipboard operation
WebAuthn copied to clipboard

Help moving to async/await

Open ghost opened this issue 5 years ago • 1 comments

HI guys, any one can submit a code using async/await as an example for a super simple client implemnetations, the callback hell makes it super hard to understand the dynammic of the library...

Any help will be so much appreciated

ghost avatar Feb 25 '20 15:02 ghost

well took me a bit to decode it as well back then but the basic thing is simple enough (just takes a ton of text to describe it):

basic info about the flows: both registration have 3 flows: the initialization on the server for the client to interact with the fido stick (aka preparing the arguments), the actual javascript to do the interacting and the server side finalization of the thing, aka checking whether all is okay and then act upon it (store the new registration or authenticate one that has been stored already)

also you need an object to actually do stuff, which you can create by

$formats=array('fido-u2f', 'packed', 'android-key', 'android-safetynet', 'none'); //or any subset of these, basically controls which attestations are allowed
$WebAuthn = new \WebAuthn\WebAuthn('some funny name', $_SERVER["HTTP_HOST"], $formats);

for simplicity I just have the hostname being set dynamically by PHP for portability.

  1. for registration init, you have to call
$args=$WebAuthn->getCreateArgs($uid, $uname, $dname, $timeout,$rk,$uv,$exist);
$getArgs = json_encode($args);

session_start();
$_SESSION['my1sb'] = array("c"=>$WebAuthn->getChallenge(),"uid"=>$uid,"uv"=>$uv);
Variable description uid is a "user ID", an arbitrary value which at least ok resident credentials gets stored and sent back on login, but doesnt do overly much

uname and dname are the "name" and "display name" respectively

both displayed when a resident sign in happens if a device has multiple RKs for any given relying Party (aka website). generally the display name is supposed to be a nice human readable name, which would be like a user's real name or the DIsplay name on the website or whatever the user may want to see there

the "name" thing on the other hand is there for a user to distinguish multiple credentials with the same display name, so that could for example be a unique login-name or email address or whatever. or as the webauthn standard says: "displayname should be a name, name should be an identifier.

timeout is the timeout in seconds for the prompt to stay open until it fails for timeout (does not prevent any retries by starting the js function again)

rk is a boolean, which basically says whether or not this should be a resident credential for usernameless authentication. be careful with this as this

  1. locks out devices that cannot deal with resident keys like U2F ones.
  2. is (sometimes severly) limited (for example Yubikeys only allow 25)
  3. most Fido2 devices do NOT allow deleting single RKs, you have to wipe the entire device, which is kinda ugly (also most dont support updates, making this a permanent problem for existing devices)

uv can be either a boolean or a string to do the following things:

  1. "discouraged": try not to ask for UV (some authenticators simply may not careand still ask but that's a different problem, most just let one through with a tap of the button
  2. false or "preferred": if the authenticator has a UV set, use it. if it is not set or not supported (U2F for example) do not use it. in my opinion this mode is kinda bad because it gives a false sense of security because unless you actually keep track of which authenticators use UV or not and enforce that on the server (see next step), one can just replace with discouraged and get in without the PIN.
  3. true or "required", which basically is "use UV or forget it", if a device can UV but hasnt set it, it will guide the client towards setting it (like creating a new PIN) or of a device cannot UV, it will tell the user that they cant use that device.

exist is basically an array of the credentialIDs (in binary) of the existing credentials for a user to avoid registering the same device multiple times.

on the end we get a pretty array with args, which we need to convert to JSON do the client can deal with it.

also you need to store the challenge and stuff for later so I do it in the session.

after that we get to the client side of things, but on top of a bit of our own logic and stuff we need a 2 helper functions to deal with the fact that the functions in javascript deals with arraybuffers (basically they are a bytestring, but in js) the whole block I use looks a bit like this:

echo <<<end
<script>
var args=$createArgs;

function recursiveBase64StrToArrayBuffer(r){if("object"==typeof r)for(let t in r)if("string"==typeof r[t]){let n=r[t];if("?BINARY?B?"===n.substring(0,"?BINARY?B?".length)&&"?="===n.substring(n.length-"?=".length)){n=n.substring("?BINARY?B?".length,n.length-"?=".length);let f=window.atob(n),o=f.length,i=new Uint8Array(o);for(var e=0;e<o;e++)i[e]=f.charCodeAt(e);r[t]=i.buffer}}else recursiveBase64StrToArrayBuffer(r[t])}function arrayBufferToBase64(r){for(var e="",t=new Uint8Array(r),n=t.byteLength,f=0;f<n;f++)e+=String.fromCharCode(t[f]);return window.btoa(e)}

recursiveBase64StrToArrayBuffer(args);
function webreg() {
    navigator.credentials.create(args)
        .then(result => {
            r={};
            r.clientDataJSON = result.response.clientDataJSON  ? arrayBufferToBase64(result.response.clientDataJSON) : null;
            r.attestationObject = result.response.attestationObject ? arrayBufferToBase64(result.response.attestationObject) : null;
            document.getElementById("regdata").value=JSON.stringify(r);
            document.getElementById("regform").submit();
        })
        .catch(e => {
            window.exc=e;
            console.log(e.message);
        });
}
webreg();
</script>
end;
explanation so on the start we set a js variable args, which has its contents set to the php Variable $createArgs (note that I am using heredoc syntax so PHP automatically inserts the variable for me)

DO NOTE though that we are not using quote marks here as for the js it is not supposed to be a string and json can basically be read by js as an array/object/whatever.

after that, we need the helper functions for converting arraybuffers into base64 strings and back (also any base64 strings to be used as binary buffer are marked by ?BINARY?B? at the start and ?= at the end.)

I have minified the block because we dont really need to deal with it aside from having it, so just keep it ready. the next steps are where we get into the actual meat:

for starters we use recursiveBase64StrToArrayBuffer() to recursively make all marked base64 strings into arraybuffers, so we can use those.

then we define a function (we dont really have to if we just want to immediately execute it and only once, but I prefer having a retry button and some clients may not appreciate the webauthn being triggered right away and might overlook it.)

that function starts the navigator.credentials.create() function of the browser with the now converted arguents to start the webauthn. and as it is a promise you have to deal with it in a slightly special way and it doesnt use a normal try-catch for exceptions (something someone who hasnt really done a lot of JS cant really expect, lol), but simply said, it at least for me looks more like an if then else block except that the else comes with a free exception on top and is called catch and then also gets a result tacked along.

in the then block we take the result and now it's a bit up to taste how you continue.

you just need to transmit the clientdatajson and the attestationobject (or null if there is no attestation) to the server in some way to process it.

I use a simple form approach and convert the arraybuffers to base64, throw them both into a new object, make it json, throw that into a hidden input and just send it off. you can go ajax is you like, but I dont, so I dont.

in case anything goes wrong, I for my little sandbox approach just throw the error message into the console and get done with it.

the next step is probably the biggest, the server side finalization. Let's do this in chunks instead:

the stuff
session_start();
$r=json_decode($_POST["regdata"]);
$clientDataJSON = base64_decode($r->clientDataJSON);
$attestationObject = base64_decode($r->attestationObject);

$challenge=$_SESSION["my1sb"]["c"];
$uid=$_SESSION["my1sb"]["uid"];
$uv=$_SESSION["my1sb"]["uv"];

first we grab both our result json we just sent from JS via the form and also the challenge, userid and uv type from session

and with all in hand we go ham

$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge,($uv==="required"));

basically throw all the stuff we just obtained into the processCreate function (do note that if you use true instead of "required" for UV that you have to make that accordingly, in the process functions uv is just a boolean as to whether to enforce UV or not to.

$data->credentialId=base64_encode($data->credentialId);
$data->AAGUID=bin2uuid($data->AAGUID); //convert binary into a UUID format
$data->signatureCounter=($data->signatureCounter === NULL ? 0 : $data->signatureCounter);

so I get back a pretty data object which, I directly process to

  1. make the AAGUID into a UUID-style format (with a function I got from stackoverflow)
  2. if the signature counter is NULL (not all devices return a valid counter on registration, just to make it zero.

now we have the following datapoints:

  • $data->credentialId, the credentialID which will be used to identify the credential
  • $data->credentialPublicKey, the public key
  • $data->signatureCounter, the counter (or zero)
  • $data->AAGUID, basically something like a model identifier (or null if a format without AAGUIDs was used like U2F or no attestation was given)
  • $data->certificate, the certificate confirming the autenticity of the device (or null if no attestation was given)
  • the uid from Session

store those however you want, but make sure that credentialID, public key and ideally the counter are available for retrieval

so now for authentication

Init and preparation

first we need to establish whether or not to use RK and UV, which you should depending on what your scenario is, however do note that when using RK, UV generally is forced upon you by the client. as with the init for registration, you get the three choices of uv of requried, preferred or discouraged, depending on what you need. (read the notes in the registration init) while RK isnt set explicitly anywhere, it does change some things.

first we start with an empty array for the existing creds.

so if you DO use an RK, SKIP the following:

Steps for using "normal" credentials

  • look up all existing credential IDs, as the device will need them to reconstruct the keypair
  • fill an array with the BINARY credentialIDs

so basically if we do use RKs, we just keep the array empty.

again we create our set of args and encode them:

//decode the base64 credential IDs into binary by reference
function array_decode(&$item) {
     $item = base64_decode($item);
}
if($uid) {
//escape the User-ID if it's userinput
    $dbuid=mysqli_real_escape_string($link,$uid);
//pull it from the db
    $q="select credid from $table where uid='$dbuid'";
//do a big fetch all as an assoc array
    $res=mysqli_fetch_all(mysqli_query($link,$q),MYSQLI_ASSOC);
//get only one "column" of the assoc array with the credential IDs as a list
    $exist=array_column($res,"credid");
//run array walk to decode the credential IDs
    array_walk($exist, 'array_decode');
}
else {
//if no user ID and we run resident, just use an empty array
    $exist=[];
}
$args = $WebAuthn->getGetArgs($exist, $timeout, true, true, true, true, $uv);
$getArgs = json_encode($args);

then you store your stuff into the session as before,

Client Side actions for Auth
echo <<<end

<script>
var args=$getArgs;

function recursiveBase64StrToArrayBuffer(r){if("object"==typeof r)for(let t in r)if("string"==typeof r[t]){let n=r[t];if("?BINARY?B?"===n.substring(0,"?BINARY?B?".length)&&"?="===n.substring(n.length-"?=".length)){n=n.substring("?BINARY?B?".length,n.length-"?=".length);let f=window.atob(n),o=f.length,i=new Uint8Array(o);for(var e=0;e<o;e++)i[e]=f.charCodeAt(e);r[t]=i.buffer}}else recursiveBase64StrToArrayBuffer(r[t])}function arrayBufferToBase64(r){for(var e="",t=new Uint8Array(r),n=t.byteLength,f=0;f<n;f++)e+=String.fromCharCode(t[f]);return window.btoa(e)}

recursiveBase64StrToArrayBuffer(args);
function websig() {
    navigator.credentials.get(args)
        .then(result => {
            r={};
            r.clientDataJSON = result.response.clientDataJSON  ? arrayBufferToBase64(result.response.clientDataJSON) : null;
            r.authenticatorData = result.response.authenticatorData ? arrayBufferToBase64(result.response.authenticatorData) : null;
            r.signature = result.response.signature ? arrayBufferToBase64(result.response.signature) : null;
            r.id = result.rawId ? arrayBufferToBase64(result.rawId) : null;
            document.getElementById("sigdata").value=JSON.stringify(r);
            document.getElementById("sigform").submit();
        })
        .catch(e => {
            window.exc=e;
            console.log(e.message);
        });
}
websig();
</script>
end;

we basically do something very similar to before but with some changes:

  • navigator.credentials.get instead of .create
  • we now have 4 values to deal with instead of just 2, the rawID, signature, authenticatorData, and clientDataJSON
  • otherwise it's basically the same: insert the json string into the JS variable, no quotes, and the insert the block of helper functions, decode the thing into the array buffer and then have the function ready for webauthn, if all goes right, transmit to server, if not, tell the user.
authentication finalization is a bit more annoying as you have to prepare a bit more.
//pull challenge data from session
session_start();
$challenge=$_SESSION["my1sb"]["c"];
$uid=$_SESSION["my1sb"]["uid"];
$uv=$_SESSION["my1sb"]["uv"];
//pull response data from POST
$r=json_decode($_POST["sigdata"]);
$clientDataJSON = base64_decode($r->clientDataJSON);
$signature = base64_decode($r->signature);
$authenticatorData = base64_decode($r->authenticatorData);
$cid = $r->id;
//----> insert getting the pubkey and counter here <---
try {
$res=$WebAuthn->processGet($clientDataJSON,$authenticatorData,$signature,$array['pk'],$challenge,$array["counter"],($uv==="required"));
}

now you have to pull the public key and ideally the counter (to make sure the key isnt cloned or whatever) from the database with the credential ID ($cid)

if you dont want to use a counter, just use 0 then it probably won't matter

also if you run remote credentials, make sure that the credential ID actually belongs to the user you are logging in (as you know that already), in case of Resident Keys you need to get the internal user data yourself.

then the webauthn processGet will either create a result object or throw an exception, so use a try-catch here. (exceptions are for basically anything that doesnt let the Auth go through, like no UV when you weant UV, or the counter being lower than what you stored)

if you have nothing to catch you get a nice true returned.

after that if you use the counter, you need to update it in the db, and you get the coutner for the auth via this command

$counter=$WebAuthn->getSignatureCounter()

now I know this is a lot of text but I do hope it helps. the code I inserted here is based off my webauthn sandbox which is powered by this nice lib https://my1.dev/wa/my1.php code: https://keybase.pub/my1/webauthn.php SQL Database: https://keybase.pub/my1/webauthn.sql

I have not included in this description some mroe advanced techniques I use in my sandbox like telling a user whether or not they did use UV by reading the flags or to make sure a user cant replace "preferred" with "discouraged", something I wrote about in my blog. https://blog.my1.dev/webauthns-userverificationpreferred-and-its-pitfalls

My1 avatar Mar 20 '20 12:03 My1

async done with 7906456c7a1a4a29b8267065a326d0d0da89857e

lbuchs avatar Dec 13 '22 17:12 lbuchs