Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verifying/Decoding Apple's x5c JWT signatures #465

Open
kennywyland opened this issue Oct 11, 2022 · 5 comments
Open

Verifying/Decoding Apple's x5c JWT signatures #465

kennywyland opened this issue Oct 11, 2022 · 5 comments

Comments

@kennywyland
Copy link

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
Copy link
Author

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));

@kho-Co
Copy link

kho-Co commented Apr 21, 2023

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.

@bshaffer
Copy link
Collaborator

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.

@timoschinkel
Copy link

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?

@bshaffer
Copy link
Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants