php-jwt icon indicating copy to clipboard operation
php-jwt copied to clipboard

Verifying/Decoding Apple's x5c JWT signatures

Open kennywyland opened this issue 3 years ago • 9 comments

I tried using the auth keys published on Apple's website:

$signedTransactionJWT = $response['signedTransactions'][0];

$appleKeysText = file_get_contents('https://appleid.apple.com/auth/keys');

$jwks = json_decode($appleKeysText, true);
$keyset = JWK::parseKeySet($jwks);
$decodedTransactionPayload = JWT::decode($signedTransactionJWT, $keyset);

...but it horks with the following error:

Fatal error: Uncaught UnexpectedValueException: "kid" empty, unable to lookup correct key

I looked through the JWT::decode() method, and it's looking for a key id ("kid") in the header of the signed transaction JWT, but Apple doesn't provide a "kid" in the header of the signed transaction JWT. The structure of the header looks like this:

{
    "alg": "ES256",
    "x5c": [
        "MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1M...",
        "MIIDFjCCApygAwIBAgIUIsGhRwp0c2nvU4YSycafPTjzbNcwCgYIKoZIzj0EA...",
        "MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEA..."
    ]
}

I'm an experienced developer in a hundred other topics, but this is my first time working with JWTs, so I'm doing my best to understand the various interacting pieces here.

How can I properly decode/verify the JWTs with x5c from Apple?

kennywyland avatar Oct 11 '22 19:10 kennywyland

I figured it out with the excellent help provided on Stack Overflow.

The first item in the x5c array is the certificate used to sign the JWT and that certificate holds the public key.

The certs in the x5c array are DER certs, but openssl wants PEM certs when it does verification. As far as I can tell, converting a DER cert to a PEM cert just involves taking the DER data, base64 encoding it, limiting it to 64 characters wide per line, then wrapping it in -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----. But the DER data in the x5c array is already base64 encoded, so we can skip that step.

list($headerb64, $bodyb64, $cryptob64) = explode('.', $jwt);
$headertext = JWT::urlsafeB64Decode($headerb64);
$header = JWT::jsonDecode($headertext);
$dercertificateb64 = $header->x5c[0];
$wrappedcertificatetext = trim(chunk_split($dercertificateb64, 64));
$certificate = <<<EOD
-----BEGIN CERTIFICATE-----
$wrappedcertificatetext
-----END CERTIFICATE-----
EOD;
print "cert:\n$certificate\n";

The php-jwt library will take an OpenSSLAsymmetricKey object as the key data, and openssl_pkey_get_public() will return that type of object. You can pass the PEM certificate string into that function and it'll parse and extract the public key:

$publickey = openssl_pkey_get_public($certificate);
$decoded = JWT::decode($jwt, new Key($publickey, $header->alg));

kennywyland avatar Oct 12 '22 18:10 kennywyland

I figured it out with the excellent help provided on Stack Overflow.

The first item in the x5c array is the certificate used to sign the JWT and that certificate holds the public key.

The certs in the x5c array are DER certs, but openssl wants PEM certs when it does verification. As far as I can tell, converting a DER cert to a PEM cert just involves taking the DER data, base64 encoding it, limiting it to 64 characters wide per line, then wrapping it in -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----. But the DER data in the x5c array is already base64 encoded, so we can skip that step.

list($headerb64, $bodyb64, $cryptob64) = explode('.', $jwt);
$headertext = JWT::urlsafeB64Decode($headerb64);
$header = JWT::jsonDecode($headertext);
$dercertificateb64 = $header->x5c[0];
$wrappedcertificatetext = trim(chunk_split($dercertificateb64, 64));
$certificate = <<<EOD
-----BEGIN CERTIFICATE-----
$wrappedcertificatetext
-----END CERTIFICATE-----
EOD;
print "cert:\n$certificate\n";

The php-jwt library will take an OpenSSLAsymmetricKey object as the key data, and openssl_pkey_get_public() will return that type of object. You can pass the PEM certificate string into that function and it'll parse and extract the public key:

$publickey = openssl_pkey_get_public($certificate);
$decoded = JWT::decode($jwt, new Key($publickey, $header->alg));

Thank you! This helped me.

kho-Co avatar Apr 21 '23 15:04 kho-Co

So if I'm understanding this correctly, the x5c header of the JWT contains the certificate.

To solve your issue generally in the library, we would be able to do something like make the second parameter optional, and if the x5c header exists, use that as the key. We would then need to do the formatting for openssl as you have here.

Some things I don't understand

  • If the x5c is used to verify the token, what is the point of the linked JKWS?
  • Why are there multiple entries in x5c, and why do we just use the first one? Can we rely on that being the case, or should we try with them all?

I imagine these questions could be answered with a bit more research on x5c, but it seems like this would make for a nice library enhancement.

bshaffer avatar Jun 28 '23 22:06 bshaffer

Why are there multiple entries in x5c, and why do we just use the first one? Can we rely on that being the case, or should we try with them all?

From the explanation of Auth0 - https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-set-properties:

x5c | The x.509 certificate chain. The first entry in the array is the certificate to use for token verification; the other certificates can be used to verify this first certificate.

So the first certificate should contain the key, any other certificates are part of the chain and are used to verify the first certificate.

About the missing kid, the specification - https://datatracker.ietf.org/doc/html/rfc7517#section-4.5 - states that this property is optional. I guess if no kid is specified you should try every key?

timoschinkel avatar Jun 30 '23 14:06 timoschinkel

any other certificates are part of the chain and are used to verify the first certificate.

That's interesting - so is it recommended that all certificates in the chain are verified, or only the first one?

what is the point of the linked JWKS?

From ietf rfc:

The key in the first certificate MUST match the public key represented by other members of the JWK

bshaffer avatar Jun 30 '23 15:06 bshaffer