Verify JWS response from Android SafetyNet using PHP

1.5k Views Asked by At

Summary

I am able to get a JWS SafetyNet attestation from Google's server and send it to my server. The server runs PHP.

How do I "Use the certificate to verify the signature of the JWS message" using PHP on my server?

What I have been doing

I do know how to just decode payload and use that, but I also want to make sure the JWS has not been tampered with. I.e. "Verify the SafetyNet attestation response" on the official documentations at https://developer.android.com/training/safetynet/attestation

I want to use some already made library/libraries for doing this but I get stuck.

At first I tried using the https://github.com/firebase/php-jwt library and the decode-method. The problem is that it wants a key, and I have so far been unable to figure out what key it needs. I get PHP Warning: openssl_verify(): supplied key param cannot be coerced into a public key in .... So, it wants some public key... of something...

The offical doc has 4 points:

  1. Extract the SSL certificate chain from the JWS message.
  2. Validate the SSL certificate chain and use SSL hostname matching to verify that the leaf certificate was issued to the hostname attest.android.com.
  3. Use the certificate to verify the signature of the JWS message.
  4. Check the data of the JWS message to make sure it matches the data within your original request. In particular, make sure that the timestamp has been validated and that the nonce, package name, and hashes of the app's signing certificate(s) match the expected values.

I can do 1 and 2 (partially at least), with the help of internet:

list($header, $payload, $signature) = explode('.', $jwt);
$headerJson = json_decode(base64_decode($header), true);
$cert = openssl_x509_parse(convertCertToPem($headerJson['x5c'][0])); 
...
function convertCertToPem(string $cert) : string
    {
        $output  = '-----BEGIN CERTIFICATE-----'.PHP_EOL;
        $output .= chunk_split($cert, 64, PHP_EOL);
        $output .= '-----END CERTIFICATE-----'.PHP_EOL;
        return $output;
    }  

Manually checking header content says it has attributes alg and x5c. alg can be used as valid algorithm to the decode-call. x5c has a list of 2 certs, and according to the spec the first one should be the one (https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-signature-36#section-4.1.5)

I can check the CN field of the certificate that it matches, $cert['subject']['CN'] === 'attest.android.com' and I also need to validate the cert chain (have not been working on that yet).

But how do I use the certificate to validate the jwt?

According to How do I verify a JSON Web Token using a Public RSA key? the certificate is not the public one and that you could:

$pkey_object = openssl_pkey_get_public($cert_object);
$pkey_array = openssl_pkey_get_details($pkey_object);
$publicKey = $pkey_array ['key'];

but I get stuck on the first line using my $cert openssl_pkey_get_public(): key array must be of the form array(0 => key, 1 => phrase) in ...

Notes

I guessed I needed at least something from outside the jws data, like a public key or something... or is this solved by the validation of the cert chain to a root cert on the machine?

I want to make this work production-wise, i.e. calling the api at google to verify every jws is not an option.

Other related(?) I have been reading (among a lot of unrelated pages too):

No longer existing lib that is linked from some sources:

2

There are 2 best solutions below

0
On BEST ANSWER

quite late but for people who wonder

try decoding signature using base64Url_decode

below code should work

$components = explode('.', $jwsString);
if (count($components) !== 3) {
    throw new MalformedSignatureException('JWS string must contain 3 dot separated component.');
}

$header = base64_decode($components[0]);
$payload = base64_decode($components[1]);
$signature = self::base64Url_decode($components[2]);
$dataToSign = $components[0].".".$components[1];        
$headerJson = json_decode($header,true);    
$algorithm = $headerJson['alg']; 
echo "<pre style='white-space: pre-wrap; word-break: keep-all;'>$algorithm</pre>";

$certificate = '-----BEGIN CERTIFICATE-----'.PHP_EOL;    
$certificate .= chunk_split($headerJson['x5c'][0],64,PHP_EOL);
$certificate .= '-----END CERTIFICATE-----'.PHP_EOL;

$certparsed = openssl_x509_parse($certificate,false);
print_r($certparsed);

$cert_object = openssl_x509_read($certificate);
$pkey_object = openssl_pkey_get_public($cert_object);
$pkey_array = openssl_pkey_get_details($pkey_object);
echo "<br></br>";
print_r($pkey_array);
$publicKey = $pkey_array ['key'];
echo "<pre style='white-space: pre-wrap; word-break: keep-all;'>$publicKey</pre>";

$result = openssl_verify($dataToSign,$signature,$publicKey,OPENSSL_ALGO_SHA256);
if ($result == 1) {
    echo "good";
} elseif ($result == 0) {
    echo "bad";
} else {
    echo "ugly, error checking signature";
}
openssl_pkey_free($pkey_object);


private static function base64Url_decode($data)
{
    return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
}
1
On

I got public key from x509 certificate using below code. But signature validation always fail. Is it the correct public key for verification? Can't post comment so posting as an answer.

    $components = explode('.', $jwsString);
    if (count($components) !== 3) {
        throw new MalformedSignatureException('JWS string must contain 3 dot separated component.');
    }

    $header = base64_decode($components[0]);
    $payload = base64_decode($components[1]);
    $signature = base64_decode($components[2]);
    $dataToSign = $components[0].".".$components[1];        
    $headerJson = json_decode($header,true);    
    $algorithm = $headerJson['alg']; 
    echo "<pre style='white-space: pre-wrap; word-break: keep-all;'>$algorithm</pre>";

    $certificate = '-----BEGIN CERTIFICATE-----'.PHP_EOL;    
    $certificate .= chunk_split($headerJson['x5c'][0],64,PHP_EOL);
    $certificate .= '-----END CERTIFICATE-----'.PHP_EOL;

    $certparsed = openssl_x509_parse($certificate,false);
    print_r($certparsed);

    $cert_object = openssl_x509_read($certificate);
    $pkey_object = openssl_pkey_get_public($cert_object);
    $pkey_array = openssl_pkey_get_details($pkey_object);
    echo "<br></br>";
    print_r($pkey_array);
    $publicKey = $pkey_array ['key'];
    echo "<pre style='white-space: pre-wrap; word-break: keep-all;'>$publicKey</pre>";
    
    $result = openssl_verify($dataToSign,$signature,$publicKey,OPENSSL_ALGO_SHA256);
    if ($result == 1) {
        echo "good";
    } elseif ($result == 0) {
        echo "bad";
    } else {
        echo "ugly, error checking signature";
    }
    openssl_pkey_free($pkey_object);