WebAuthn icon indicating copy to clipboard operation
WebAuthn copied to clipboard

BUG? 'invalid challenge' ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()

Open russmenum opened this issue 5 months ago • 3 comments

OK, with a virtual authenticator the demo seems to work, my code is basically that code, but somehow I am getting a challenge mismatch case that prevents registration

My ERROR case:

if ($fn === 'getCreateArgs') {
				$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
				$thisChallenge = $WebAuthn->getChallenge();
		
				// header('Content-Type: application/json');
				// print(json_encode($createArgs));
				// REFACTORED FOR USE CASE
				$this->response->type('application/json');
				echo json_encode($createArgs);
		
				$this->log('AUDIT challenge getCreateArgs: '.$thisChallenge,'error');
				$this->log('AUDIT $createArgs->publicKey->challenge: '.$createArgs->publicKey->challenge,'error');

				// save challange to session. you have to deliver it to processGet later.
				// $_SESSION['challenge'] = $WebAuthn->getChallenge();
				// REFACTORED FOR USE CASE
				$this->loadModel('Crypto');
				$this->Crypto->create();
				$cryptoKey = $this->genKey();
				$crypto['Crypto']['key'] = $cryptoKey;
				$crypto['Crypto']['value'] = $WebAuthn->getChallenge();
				$this->Crypto->save($crypto,false);
				$this->Session->write('User.challengeKey',$cryptoKey); 
				unset($crypto,$cryptoKey);

		
			// ------------------------------------
			// request for get arguments
			// ------------------------------------
		
			} 

used $thisChallenge and $WebAuthn->getChallenge() to sanity verify no one behind but as expect both values MATCH. THE LOG:

2025-08-06 08:24:20 Error: AUDIT challenge getCreateArgs: 08511e9bc45c24a84e025da279c5d36b93e95a95756cc7ee56ab02489ca85e44
2025-08-06 08:24:20 Error: AUDIT $createArgs->publicKey->challenge: 08511e9bc45c24a84e025da279c5d36b93e95a95756cc7ee56ab02489ca85e44

in the processCreate validating MATCH test values

else if ($fn === 'processCreate') {
				$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
				$crypto = $this->Crypto->find('first',array('recursive'=> -1,'conditions'=>array('Crypto.key'=>$cryptoKey)));
				$this->log('AUDIT challenge VALUE crypto: ','error');
				$this->log( $crypto,'error');
				$this->log( 'client JSON DUMP','error');
				$this->log( json_decode($clientDataJSON),'error');

LOGGED VALUES:

2025-08-06 08:24:20 Error: AUDIT challenge VALUE crypto: 
2025-08-06 08:24:20 Error: Array
(
    [Crypto] => Array
        (
            [id] => 51
            [key] => hn756je7b6
            [value] => 08511e9bc45c24a84e025da279c5d36b93e95a95756cc7ee56ab02489ca85e44
        )

)
2025-08-06 08:24:20 Error: client JSON DUMP
2025-08-06 08:24:20 Error: stdClass Object
(
    [type] => webauthn.create
    [challenge] => RjXw6VKmqajvMCoAIuPfKxZmuTlUY8Z3QlM9Dn83Cxw
    [origin] => [THE ORIGIN URL]
    [crossOrigin] => 
)

To check what it is getting modified my local WebAuthn.php for this error case to return the values and logged

WebAuthn.php

 // 4. Verify that the value of C.challenge matches the challenge that was sent to the authenticator in the create() call.
        if (!\property_exists($clientData, 'challenge') || ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()) {
            // throw new WebAuthnException('invalid challenge', WebAuthnException::INVALID_CHALLENGE);
            
            // DEBUG THIS MIS MATCH
            $DEBUGTHISVALUE = ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString();//trying to show this "STRING" JSON 
            $debug = array(
                "server" => $challenge->getBinaryString(),
                "client" => $DEBUGTHISVALUE,
            );
            return $debug;
        }

lse if ($fn === 'processCreate') {

$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false);
$this->log( 'DATA DUMP','error');
$this->log( $data,'error');

RESULTING LOG

2025-08-06 08:24:20 Error: DATA DUMP
2025-08-06 08:24:20 Error: Array
(
    [server] => 08511e9bc45c24a84e025da279c5d36b93e95a95756cc7ee56ab02489ca85e44
    [client] => F5��R����0*

russmenum avatar Aug 06 '25 13:08 russmenum

I came across a similar issue when authenticating across multiple domains. (JS sits on one domain and PHP on a different server and different domain) the subsequent queries get different session.

example.com JS page makes call to api.example.com/server.php?fn=getGetArgs [gets a PHPSESSID) example.com JS page makes call to api.example.com/server.php?fn=processGet [gets a different PHPSESSID)

after getting around the CORS issues, I tried PHP side: header("Access-Control-Allow-Credentials: true"); header("Access-Control-Allow-Origin: " . $http_origin); session_set_cookie_params([ 'domain' => '.example.com', 'secure' => true, 'httponly' => true, 'samesite' => 'None' ]);

JS side: // send to server rep = await window.fetch('https://api.example.com/server.php?fn=processGet', { method:'POST', body: JSON.stringify(authenticatorAttestationResponse), cache:'no-cache', credentials: 'include' });

I couldn't get the session ID to maintain (causing a new challenge to be created on each fetch) the below two links also discuss similar problems. With the recommendation seeing to be using JWT or encrypting the challenge and using the client as storage to decrypt and re-authenticate the challenge. https://matteing.com/posts/storing-challenge-nonces-passkeys https://security.stackexchange.com/questions/268308/how-to-properly-manage-webauthn-challenges

Michael-MCP avatar Sep 08 '25 01:09 Michael-MCP

@Michael-MCP well, it is supposed to make a new challenge each fetch

HIGH LEVEL:

  • client fetch server
  • server makes a challenge
  • severe cashe challenge
  • server send payload to client
  • client hash/auth what not
  • client responds to server
  • server compares client response to cached challenge for AUTH and VALIDATION

I finally "figured this out", but my patch feels hacky...

The issue is 32BIt key/chalange and 64Bit server/PHP, in WebAuthn.php made private $_challenge; public instead of private, so

public function getChallenge() {
        return $this->_challenge;
    }

would allow $WebAuthn->getChallenge()->_data to be what the server stores for compare.

Basically ByteBuffer::randomBuffer($length); is using random_bytes($length) which will return 32 bit in 64 bit; but depending on your server can still get converted to the 64bit string depending how you read and store unless accessed more raw...

So for anyone getting the 2 look like mine, it's just one of those PHP being PHP, and this is the HACK/Patch when you are in a case where $WebAuthn->getChallenge()->_data != $WebAuthn->getChallenge() after cache or store server side...

russmenum avatar Sep 08 '25 15:09 russmenum

I misunderstood your first post thinking this to be about different domains. I had a look and I managed to recreate the issue. of course if the challenge always stays in session there is no issue, so this only occurred for me when working across multiple domains (Front end on one domain and api on another). When converting my JWT back to binary it was indeed becoming a 64bit binary. Below is the response data as I was debugging:

Image

Array of JWT json decoded and base64 decoded payload: array(2) { ["userID"]=> string(86) "ZWM2ZmQyYTVhNjg4OTU3MDNhZDk1YTQzZmQzOWVjMTVlMmFlMDAxM2ExMzNjYTZjYmExZWQ3NjEzM2RkNTljZQ" ["displayname"]=> string(10) "unverified" } stringZWM2ZmQyYTVhNjg4OTU3MDNhZDk1YTQzZmQzOWVjMTVlMmFlMDAxM2ExMzNjYTZjYmExZWQ3NjEzM2RkNTljZQpassing the challenege back in to ByteBuffer::fromBase64Url: object(lbuchs\WebAuthn\Binary\ByteBuffer)#7 (2) { ["_data":"lbuchs\WebAuthn\Binary\ByteBuffer":private]=> string(64) "ec6fd2a5a68895703ad95a43fd39ec15e2ae0013a133ca6cba1ed76133dd59ce" ["_length":"lbuchs\WebAuthn\Binary\ByteBuffer":private]=> int(64) } binary from hex2bin of ByteBuffer: string(64) "ec6fd2a5a68895703ad95a43fd39ec15e2ae0013a133ca6cba1ed76133dd59ce" echo out of Challenge from cookie JWT: 65633666643261356136383839353730336164393561343366643339656331356532616530303133613133336361366362613165643736313333646435396365 echo out of Challenge from session. : ec6fd2a5a68895703ad95a43fd39ec15e2ae0013a133ca6cba1ed76133dd59ce var_dumping challenge: object(lbuchs\WebAuthn\Binary\ByteBuffer)#7 (2) { ["_data":"lbuchs\WebAuthn\Binary\ByteBuffer":private]=> string(64) "ec6fd2a5a68895703ad95a43fd39ec15e2ae0013a133ca6cba1ed76133dd59ce" ["_length":"lbuchs\WebAuthn\Binary\ByteBuffer":private]=> int(64) } var_dumping session challenge: object(lbuchs\WebAuthn\Binary\ByteBuffer)#2 (2) { ["_data":"lbuchs\WebAuthn\Binary\ByteBuffer":private]=> string(32) "�oҥ���p:�ZC�9��

Michael-MCP avatar Oct 21 '25 01:10 Michael-MCP