Generate an HS512 JWT in PHP without libraries? - php

I'm close but jwt.io doesn't like the signature I generate. With this code I generate the following JWT. If this ain't the way, how should I be generating a JWT in PHP if I can't use external libraries?
function gen_jwt():String{
$signing_key = "changeme";
// header always eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9
$header = [
"alg" => "HS512",
"typ" => "JWT"
];
$header = base64_url_encode(json_encode($header));
log_message('debug',__CLASS__.'('.__FUNCTION__.':'.__LINE__.') json base64_url_encode: ' . $header);
// test case 0 generates (on jwt.io):
// with secret base64 encoded: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.ZW0gOCJV4e1KgGEsw0bL7oCF1AI1PBL8VVgSoss4tmr7682p6DpNc1uGbBpOEfkPjKJv0JBnLvjH2XUbo8PHUg
// without secret b64 encoded: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.pqzfdCTmr-eWW9sEgV-COCdS4dI7MDpCIFWss6kXnAC9eLdGX1qOOr8BtJih59o_U_AdHtBh8JwUQ4dEPTk0rg
$payload = [
// "exp" => time() + ...,
"exp" => 0,
];
$payload = base64_url_encode(json_encode($payload));
$signature = hash_hmac('sha512', "$header.$payload", $signing_key, false);
log_message('debug',__CLASS__.'('.__FUNCTION__.':'.__LINE__.') signature: ' . $signature);
$signature = base64_url_encode($signature);
log_message('debug',__CLASS__.'('.__FUNCTION__.':'.__LINE__.') signature: ' . $signature);
// all three parts b64 url-encoded
$jwt = "$header.$payload.$signature";
log_message('debug',__CLASS__.'('.__FUNCTION__.':'.__LINE__.') jwt: ' . $jwt);
return $jwt;
}
/**
* per https://stackoverflow.com/questions/2040240/php-function-to-generate-v4-uuid/15875555#15875555
*/
function base64_url_encode($text):String{
return str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode($text)
);
}
/**
* per https://www.uuidgenerator.net/dev-corner/php
*/
function guidv4($data = null): String {
// Generate 16 bytes (128 bits) of random data or use the data passed into the function.
$data = $data ?? random_bytes(16);
assert(strlen($data) == 16);
// Set version to 0100
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
// Set bits 6-7 to 10
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
// Output the 36 character UUID.
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
Comes out:
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.ZmIxNzIyN2Q2ZjFhYjg3ZTJjMTY0NDJkNGQ4NzFlYWFmMjFhYzg1NzI5NGRkOGVhZmY4MTYzNWI1YTMyYWEyN2UxOTFmN2E5MzA1ZTZjZmI0OGVlZmMwN2U2NTc1MzNhZDg0NmMxMTZhZDZlOGVlYjJmMGVmOWUxOTMyYzE5MmE
...which jwt.io (and my own decoding efforts) say is an invalid signature. Help? Thanks!

I guess the trick was to base64-url-encode the binary output of the hmac like...
$signature = base64_url_encode(hash_hmac('sha512', "$header.$payload", $signing_key, true));
So the copy-paste-able code would be:
function gen_jwt():String{
$signing_key = "changeme";
$header = [
"alg" => "HS512",
"typ" => "JWT"
];
$header = base64_url_encode(json_encode($header));
$payload = [
"exp" => 0,
];
$payload = base64_url_encode(json_encode($payload));
$signature = base64_url_encode(hash_hmac('sha512', "$header.$payload", $signing_key, true));
$jwt = "$header.$payload.$signature";
return $jwt;
}
/**
* per https://stackoverflow.com/questions/2040240/php-function-to-generate-v4-uuid/15875555#15875555
*/
function base64_url_encode($text):String{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($text));
}

Related

How to generate JWT in PHP

How to generate JWT token in php using with the following parameters
Subject, Issuer, Expiry time and payload in the < PAYLOAD > tag.
Id can be any random number of any length.
subject is TestService
Issuer is Baguma Inc
Expiry Time will be 30 sec from current time(ideally ).
Payload is the request from Third Party
SigningKEY is fcvxcnfrhrtghkfghgwerikdf
Signature algorithm will be HS512.
Sample request from Third Party is shown below
<COMMAND><TYPE>REQUEST</TYPE><INTERFACE>TESTACCOUNT</INTERFACE> <REQUESTID>123</REQUESTID></COMMAND
Your answer got me started. Here's the working code I went with (albeit generalized from your specific case). Thanks for getting me started!
function gen_jwt():String{
$signing_key = "changeme";
$header = [
"alg" => "HS512",
"typ" => "JWT"
];
$header = base64_url_encode(json_encode($header));
$payload = [
"exp" => 0,
];
$payload = base64_url_encode(json_encode($payload));
$signature = base64_url_encode(hash_hmac('sha512', "$header.$payload", $signing_key, true));
$jwt = "$header.$payload.$signature";
return $jwt;
}
/**
* per https://stackoverflow.com/questions/2040240/php-function-to-generate-v4-uuid/15875555#15875555
*/
function base64_url_encode($text):String{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($text));
}
With the help of an article from DZone Security, I managed to generate a JWT token by doing the following
Define the base64UrlEncode function which replaces + with -, / with _ and = with ''.
function base64UrlEncode($text)
{
return str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode($text)
);
}
Encode the headers using base64UrlEncode
$headers = [ "alg" => "HS512"];
$headers_encoded = $this->base64url_encode(json_encode($headers));
Encode the Payload using Base64 URL encode as well
$issuedAt = time();
$payload = [
"id" =>$this->gen_uuid(), // .setId(UUID.randomUUID().toString())
"sub"=> "TestService", //Subject
"exp"=> $issuedAt+30,
"iss"=> "Baguma Inc", //issuer
"iat"=> $issuedAt, //issued at
"PAYLOAD"=> "<COMMAND><TYPE>REQUEST</TYPE><INTERFACE>TESTACCOUNT</INTERFACE> <REQUESTID>123</REQUESTID></COMMAND"];
$payload_encoded = $this->base64url_encode(json_encode($payload));
Using the Key/secret build the signature
$key = "fcvxcnfrhrtghkfghgwerikdf"
$signature = hash_hmac('sha512',"$headers_encoded.$payload_encoded",$key,true);
Build and return the token
$token = "$headers_encoded.$payload_encoded.$signature_encoded";

Different results in hmac encoding between Dart and PhP

I'm trying to encode a message in flutter and to verify it with some php code, but somehow there are some differences in the hmac encoding. The header and payload values are the same in both cases. But somehow there are some differences between the resulting values.
Any help would be very helpful, I'm stuck on this for some days now.
$base64UrlHeader = 'header';
$base64UrlPayload = 'payload';
$secret = 'secret';
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true); // 9b491a7aa29955d9d67e302965665ba0cfa4306c00470f8946eb6aa67f676595
$base64UrlSignature = base64UrlEncode($signature); // sYql52zk6tqYeGSUsDv_219UtgpK3c8-TMuko4n_L5Q
function base64UrlEncode($text) {
return str_replace(
['+', '/', '='],
['-', '_', ''],
base64_encode($text)
);
}
And this is my dart code:
// This values are obtained by using the _base64UrlEncode method from below,
// I just wrote the values directly here not to clutter with code
var base64UrlHeader = 'header';
var base64UrlPayload = 'payload';
/// SIGNATURE
var secret = utf8.encode('secret');
var message = utf8.encode(base64UrlHeader + '.' + base64UrlPayload);
var hmac = new Hmac(sha256, secret);
var digest = hmac.convert(message); // b18aa5e76ce4eada98786494b03bffdb5f54b60a4addcf3e4ccba4a389ff2f94
var signature = _base64UrlEncode(digest.toString()) // YjE4YWE1ZTc2Y2U0ZWFkYTk4Nzg2NDk0YjAzYmZmZGI1ZjU0YjYwYTRhZGRjZjNlNGNjYmE0YTM4OWZmMmY5NA
// This method is used to obtain the header, payload and signature
static String _base64UrlEncode(String text) {
Codec<String, String> stringToBase64Url = utf8.fuse(base64url);
return stringToBase64Url
.encode(text)
.replaceAll('=', '')
.replaceAll('+', '-')
.replaceAll('/', '_');
}
Both the header and payload are obtained from the same json object,

Always occurs "invalid_client" error trying to get refresh token by authorisation code during "sign with apple" implementation

I have an iOS app which implements sign in with Apple.
A private key was generated for this app on developer portal.
For the first our app server receives identity token and authorization code from iOS app. We use firebase/jwt-php library to verify identity token with keys here. After complete we have decoded identity token like this:
header:
{
"kid": "eXaunmL",
"alg": "RS256"
}
payload:
{
"iss": "https://appleid.apple.com",
"aud": "com.mycompany.myapp",
"exp": 1597336478,
"iat": 1597335878,
"sub": "000138.77a8b51895c943dcbe1ae4c34721a4c3.1312",
"nonce": "1597335873132",
"c_hash": "llDP9yFq6YOQEoi4qDzfDA",
"email": "useremail#gmail.com",
"email_verified": "true",
"auth_time": 1597335878,
"nonce_supported": true
}
Also client app send to our app server authorisation code like this:
c6b4d8ec548014979b7b7e0f4d63a173e.0.mrty.2sZAWSjybSC6MU0PQAxaag
And troubles starts...
I try to obtain refresh token by authorisation code and client secret. I don't use firebase/jwt-php library to create client secret because I read about openSSL issues here.
I've converted my .p8 private key to .pem format to use it for sign.
I got a function to generate signed jwt like this:
/**
*
* #param string $kid 10 digits key ID for my app from developer portal
* #param string $iss my 10 digits team ID from developer portal
* #param string $sub com.mycoopany.myapp
* #return string signed JWT
*/
public static function generateJWT($kid, $iss, $sub) {
$header = [
'alg' => 'ES256',
'kid' => $kid
];
$body = [
'iss' => $iss,
'iat' => time(),
'exp' => time() + 600,
'aud' => 'https://appleid.apple.com',
'sub' => $sub
];
$private_key = <<<EOD
-----BEGIN PRIVATE KEY-----
My private key converted from .p8 to .pem by command:
openssl pkcs8 -in key.p8 -nocrypt -out key.pem
-----END PRIVATE KEY-----
EOD;
$privKey = openssl_pkey_get_private($private_key);
if (!$privKey){
return false;
}
$payload = self::encode(json_encode($header,JSON_UNESCAPED_SLASHES)).'.'.self::encode(json_encode($body,JSON_UNESCAPED_SLASHES));
$signature = '';
$success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
if (!$success) return false;
$raw_signature = self::fromDER($signature, 64);
return $payload.'.'.self::encode($raw_signature);
}
private static function encode($data) {
$encoded = strtr(base64_encode($data), '+/', '-_');
return rtrim($encoded, '=');
}
To convert openSSL sign I use fromDER function from this library:
public static function fromDER(string $der, int $partLength)
{
$hex = unpack('H*', $der)[1];
if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE
throw new \RuntimeException();
}
if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128
$hex = mb_substr($hex, 6, null, '8bit');
} else {
$hex = mb_substr($hex, 4, null, '8bit');
}
if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
throw new \RuntimeException();
}
$Rl = hexdec(mb_substr($hex, 2, 2, '8bit'));
$R = self::retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit'));
$R = str_pad($R, $partLength, '0', STR_PAD_LEFT);
$hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit');
if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
throw new \RuntimeException();
}
$Sl = hexdec(mb_substr($hex, 2, 2, '8bit'));
$S = self::retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit'));
$S = str_pad($S, $partLength, '0', STR_PAD_LEFT);
return pack('H*', $R.$S);
}
/**
* #param string $data
*
* #return string
*/
private static function retrievePositiveInteger(string $data)
{
while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') {
$data = mb_substr($data, 2, null, '8bit');
}
return $data;
}
And finally my call to https://appleid.apple.com/auth/token endpoint looks like this:
$signed_jwt = opensslFix::generateJWT($my_kid, 'myTeamID', $app);
$send_data = [
'client_id' => $app,
'client_secret' => $signed_jwt,
'grant_type' => 'authorization_code',
'code' => $request->code
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://appleid.apple.com/auth/token');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([$send_data]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.2) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1090.0 Safari/536.6');
$serverOutput = curl_exec($ch);
And I always have "invalid_client" answer from the Apple server :(
What else could go wrong?
off the top of my head, try two things:
add an Accept header: 'Accept: application/x-www-form-urlencoded'
pass your post data not inside another array: http_build_query($send_data)
If I think of more things I'll edit my answer.

Apple Sign In "invalid_client", signing JWT for authentication using PHP and openSSL

I'm trying to implement Apple sign in into an Android App using this library. The main flow is described in the documentation: the library returns an authorization code on the Android side. This authorization code has to be sent to my backend which, in turn, sends it to the Apple servers in order to get back an access token.
As described here and here, in order to obtain the access token we need to send to the Apple API a list of parameters, the authorization code and a signed JWT. In particular, JWT needs to be signed with a ES256 algorithm using a private .p8 key which has to be generated and downloaded from the Apple developer portal. Apple doc
Here is my PHP script:
<?php
$authorization_code = $_POST('auth_code');
$privateKey = <<<EOD
-----BEGIN PRIVATE KEY-----
my_private_key_downloaded_from_apple_developer_portal (.p8 format)
-----END PRIVATE KEY-----
EOD;
$kid = 'key_id_of_the_private_key'; //Generated in Apple developer Portal
$iss = 'team_id_of_my_developer_profile';
$client_id = 'identifier_setted_in_developer_portal'; //Generated in Apple developer Portal
$signed_jwt = $this->generateJWT($kid, $iss, $client_id, $privateKey);
$data = [
'client_id' => $client_id,
'client_secret' => $signed_jwt,
'code' => $authorization_code,
'grant_type' => 'authorization_code'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://appleid.apple.com/auth/token');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$serverOutput = curl_exec($ch);
curl_close ($ch);
var_dump($serverOutput);
function generateJWT($kid, $iss, $sub, $key) {
$header = [
'alg' => 'ES256',
'kid' => $kid
];
$body = [
'iss' => $iss,
'iat' => time(),
'exp' => time() + 3600,
'aud' => 'https://appleid.apple.com',
'sub' => $sub
];
$privKey = openssl_pkey_get_private($key);
if (!$privKey) return false;
$payload = $this->encode(json_encode($header)).'.'.$this->encode(json_encode($body));
$signature = '';
$success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
if (!$success) return false;
return $payload.'.'.$this->encode($signature);
}
function encode($data) {
$encoded = strtr(base64_encode($data), '+/', '-_');
return rtrim($encoded, '=');
}
?>
The problem is that the response from Apple is always:
{"error":"invalid_client"}
Reading here it seems that the problem could be related to openSSL which generates a signature which is not correct for Apple ("OpenSSL's ES256 signature result is a DER-encoded ASN.1 structure (it's size exceed 64). (not a raw R || S value)").
Is there a way to obtain the correct signature using openSSL?
Is the p8 format the correct input for the openssl_sign and openssl_pkey_get_private functions?
(I noticed that the provided .p8 key does not work if used in jwt.io in order to compute the signed jwt.)
In the openSSL documentation I read that a pem key should be provided, how can I convert a .p8 in a .pem key?
I also tried with some PHP libraries which basically use the same steps described above like firebase/php-jwt and lcobucci/jwt but the Apple response is still "invalid client".
Thank you in advance for your help,
EDIT
I tried to completely remove openSSL from the equation. Using the .pem key generated from the .p8 one I have generated a signed JWT with jwt.io. With this signed JWT the Apple API replies correctly. At this point I'm almost sure it's an openSSL signature problem. The key problem is how to obtain a proper ES256 signature using PHP and openSSL.
As indicated here, the problem is actually in the signature generated by openSSL.
Using ES256, the digital signature is the concatenation of two unsigned integers, denoted as R and S, which are the result of the Elliptic Curve (EC) algorithm. The length of R || S is 64.
The openssl_sign function generates a signature which is a DER-encoded ASN.1 structure (with size > 64).
The solution is to convert the DER-encoded signature into a raw concatenation of the R and S values. In this library a function "fromDER" is present which perform such a conversion:
/**
* #param string $der
* #param int $partLength
*
* #return string
*/
public static function fromDER(string $der, int $partLength)
{
$hex = unpack('H*', $der)[1];
if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE
throw new \RuntimeException();
}
if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128
$hex = mb_substr($hex, 6, null, '8bit');
} else {
$hex = mb_substr($hex, 4, null, '8bit');
}
if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
throw new \RuntimeException();
}
$Rl = hexdec(mb_substr($hex, 2, 2, '8bit'));
$R = self::retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit'));
$R = str_pad($R, $partLength, '0', STR_PAD_LEFT);
$hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit');
if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
throw new \RuntimeException();
}
$Sl = hexdec(mb_substr($hex, 2, 2, '8bit'));
$S = self::retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit'));
$S = str_pad($S, $partLength, '0', STR_PAD_LEFT);
return pack('H*', $R.$S);
}
/**
* #param string $data
*
* #return string
*/
private static function preparePositiveInteger(string $data)
{
if (mb_substr($data, 0, 2, '8bit') > '7f') {
return '00'.$data;
}
while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') <= '7f') {
$data = mb_substr($data, 2, null, '8bit');
}
return $data;
}
/**
* #param string $data
*
* #return string
*/
private static function retrievePositiveInteger(string $data)
{
while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') {
$data = mb_substr($data, 2, null, '8bit');
}
return $data;
}
Another point is that a .pem key should be provided to the open_ssl_sign function. Starting from the .p8 key downloaded from the Apple developer I have created the .pem one by using openSSL:
openssl pkcs8 -in AuthKey_KEY_ID.p8 -nocrypt -out AuthKey_KEY_ID.pem
In the following my new generateJWT function code which uses the .pem key and the fromDER function to convert the signature generated by openSSL:
function generateJWT($kid, $iss, $sub) {
$header = [
'alg' => 'ES256',
'kid' => $kid
];
$body = [
'iss' => $iss,
'iat' => time(),
'exp' => time() + 3600,
'aud' => 'https://appleid.apple.com',
'sub' => $sub
];
$privKey = openssl_pkey_get_private(file_get_contents('AuthKey_.pem'));
if (!$privKey){
return false;
}
$payload = $this->encode(json_encode($header)).'.'.$this->encode(json_encode($body));
$signature = '';
$success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
if (!$success) return false;
$raw_signature = $this->fromDER($signature, 64);
return $payload.'.'.$this->encode($raw_signature);
}
Hope it helps

PHP JWT Invalid signature

Given this code:
function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
$key = 'secret';
//setting the header: 'alg' => 'HS256' indicates that this token is signed using HMAC-SHA256
$header = array(
'alg' => 'HS256',
'typ' => 'JWT'
);
// Returns the JSON representation of the header
$header = json_encode($header);
//encodes the $header with base64.
$header = base64url_encode($header);
$payload = array("a" => "b");
$payload = json_encode($payload);
$payload = base64url_encode($payload);
$signature = hash_hmac('SHA256','$header.$payload', $key, true);
$signature = base64url_encode($signature);
echo "$header.$payload.$signature";
Returns the following JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoiYiJ9.rhCKIvkwiuNcchxDZnGak8XT1q8lmLhnm8aIxzUioWg
But the signature is not verified at https://jwt.io/
The payload is decrypted well though... What may be the problem ?
You are using single quotes around $header.payload when calculating the HMAC, rather than double quotes; the former uses the literal string and does not expand the variables:
$signature = hash_hmac('SHA256', "$header.$payload", $key, true);

Categories