0

I have a problem decrypting a generated JWK generated by an IBM product. In particular have a look at my example:

JWE: eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTI1NktXIiwiY3R5IjoiSldUIn0.5Dx7B_0XI8F2ZZzkHjiJkeNsw11LlOuMzln9Z6OuGCAMpLeCOXnnPw.VEV_6HmnlroYO483zJdHFw.jS97NRZaPQfO46J9UvG9YsQ0po2SnUJuCe7M9VNIghD8lyUgdqaGx6xXH6MnAD01VLbjYROwh0z8CFGQ5PbamoiNxzMGM3UHDqvKU4j1pdRkcyPZbyZ6oo-NtY5dlwT6FhMMgu3kk7JKaFKXz0mhyNnvx22QTHKWHpMReEuc4AwdeDBL47iX8kT9cyqBzlGWKl-jLvEM73gUzPLC8RxG9_mtyIzEqyiGWtbDavD4yqf7lgo39jBIvwBu-VDVW05A.o15bGBayvRp9Dgzlqd2WAw

JWK: { "alg": "A256KW", "kty": "oct", "use": "enc", "k": "hD-S5Ll-StGTM6K0N891J3KdAgLVdUNRuKCpiweXJh8", "kid": "test"}

Now, this is my Python3 code:

import base64

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.keywrap import aes_key_unwrap

encrypted_jwe = ('eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTI1NktXIiwiY3R5IjoiSldUIn0'
                 '.5Dx7B_0XI8F2ZZzkHjiJkeNsw11LlOuMzln9Z6OuGCAMpLeCOXnnPw.VEV_6HmnlroYO483zJdHFw'
                 '.jS97NRZaPQfO46J9UvG9YsQ0po2SnUJuCe7M9VNIghD8lyUgdqaGx6xXH6MnAD01VLbjYROwh0z8CFGQ5PbamoiNxzMGM3UHDqvKU4j1pdRkcyPZbyZ6oo-NtY5dlwT6FhMMgu3kk7JKaFKXz0mhyNnvx22QTHKWHpMReEuc4AwdeDBL47iX8kT9cyqBzlGWKl-jLvEM73gUzPLC8RxG9_mtyIzEqyiGWtbDavD4yqf7lgo39jBIvwBu-VDVW05A.o15bGBayvRp9Dgzlqd2WAw')


jwk = {
    "kty": "oct",
    "k": "hD-S5Ll-StGTM6K0N891J3KdAgLVdUNRuKCpiweXJh8"
}

parts = encrypted_jwe.split('.')

if len(parts) != 5:
    print("invalid JWE")
    exit(1)

header, encrypted_key, iv, ciphertext, tag = parts


cek_encrypted = base64.urlsafe_b64decode( encrypted_key + '=' * (-len(encrypted_key) % 4))

cek_key = base64.urlsafe_b64decode(jwk['k'] + '=' )

cek = aes_key_unwrap(cek_key, cek_encrypted)


print("decrypted CEK:", cek)


decoded_ciphertext = base64.urlsafe_b64decode(ciphertext + '=' * (-len(ciphertext) % 4))

decoded_iv = base64.urlsafe_b64decode(iv + '=' * (-len(iv) % 4))

cipher = Cipher(algorithms.AES(cek), modes.CBC(decoded_iv), backend=default_backend())

decryptor = cipher.decryptor()
decrypted_payload = decryptor.update(decoded_ciphertext) + decryptor.finalize()

print("decrypted Payload:", decrypted_payload)

Technically speaking i think I need to do the following steps:

  1. Base64Decode of the k value of my JWK object
  2. Base64Decode of the encrypted CEK and decryption using A256KW with the key obtained from (1)
  3. With the now decrypted CEK creating an AES128CBC Decryptor in order to decrypt the Base64Decode of the ciphertext using the decoded IV provided with the JWE

However this code produces an error:

cryptography.hazmat.primitives.keywrap.InvalidUnwrap

What am I doing wrong?

I would need a baseline of a python script capable of doing this job.

Thank you

docdev
  • 943
  • 1
  • 7
  • 17
  • 1
    How do you know that the data (JWE and JWK) is correct? Why don't you use a Python JOSE implementation? – Topaco Aug 23 '23 at 16:51
  • Furthermore, `cek` must not be used for decrypting the payload, instead the authentication and encryption key must be determined from `cek` (by halving), since you are using *A128CBC-HS256* (see the JWT header). By the way, the authentication is missing completely. – Topaco Aug 23 '23 at 17:29
  • I know for sure that the data is correct because i've generated it starting from a jwt generate operation in IBM API Connect. Here an example without encryption: https://www.ibm.com/docs/en/api-connect/10.0.x?topic=SSMNED_v10cd/com.ibm.apic.apionprem.doc/tutorial_onprem_jwt_gen.htm Another intresting point is the fact i need halving the cek in order to determine the encryption key. It's the first or the second half ? By the way, i only need decryption. – docdev Aug 23 '23 at 17:58
  • 1
    With the posted data `cek` decryption fails, while with valid data it works (with another code as well as your code) which points to a data issue. The key for the encryption/decryption of the payload is the second part. – Topaco Aug 23 '23 at 18:50
  • docdev: see RFC7518 section 5.2.2.1 step 1 (MACkey then ENCkey), with the lengths set by 5.2.3 (both 16, total 32). @Topaco: auth is not missing; part 5 of the JWE is 16 bytes and is the tag=truncHMAC. But the _ciphertext_ (part 4) is not a multiple of 16 as required for AES-CBC. – dave_thompson_085 Aug 23 '23 at 21:17
  • 1
    @dave_thompson_085 - *...auth is not missing...* There is no authentication anywhere in the Python code (the fact that the JWE contains an authentication tag does not mean that the Python code performs authentication). *...But the ciphertext (part 4) is not a multiple of 16 as required for AES-CBC...* Maybe, but the decryption fails already before, namely when unwrapping the primary key. – Topaco Aug 23 '23 at 21:35
  • @dave_thompson_085 - The ciphertext (part 4) is 192 bytes in size, exactly 12 blocks, which is consistent with AES/CBC. – NotARobot Aug 24 '23 at 06:45

1 Answers1

1

The JWE token cannot be decrypted with the JWK, the problem occurs when unwrapping the primary key. A likely explanation is that the JWE token and JWK do not match.

The posted Python code works for a valid JWE token and JWK, at least as far as unwrapping the primary key is concerned. The second part, namely the decryption of the payload fails because the primary key is used as the key for decryption. Also, the decrypted payload lacks unpadding.

The correct way would be to halve the primary key. The last 16 bytes are the key for decryption. The first 16 bytes are the key for authentication via HMAC/SHA256.
You write in the comments that you don't need authentication. But this is a security component that should not be ignored (especially since the authentication effort is minimal).

To demonstrate this, I use the following valid data:

JWK:

{'kty': 'oct', 'k': 'c4OqgE8K3OMf-9W0lxa8EGSY5eeebSnVKvfZu9AssAg'}

JWE token:

eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.5ij3S9PQkJ0YPfVfoZ9Mzlu0m6_xQJU6JJnljm0IZOFvEGIYhbWk9Q.iDQqf4q20QVCMPMS-g64kw.uKD4LKZosgXcmKWMZtI4JwpjjGZMa0qDo8jRZsLnSRkI02Fr3trUTJMo4isr0TouSVnrHezgIRU0_jF-KCCDUA.4AwFYtD0o8JXaA2_Ha0AOw

The header is base64url decoded:

{"alg":"A256KW","enc":"A128CBC-HS256"}

and applies the same alg and enc value as the JWE token posted in the question. I.e. the primary key is wrapped with A256KW, for the plaintext encryption A128CBC-HS256 was used.

The following Python code is based on the code you posted, with the addition of authentication:

import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.keywrap import aes_key_unwrap
from cryptography.hazmat.primitives import hashes, hmac
from cryptography.hazmat.primitives import padding

def b64url_decode(b64data):
    b64data = b64data.encode('ascii')
    return base64.urlsafe_b64decode(b64data + b'=' * (-len(b64data) % 4))
  
# valid data
encrypted_jwe = 'eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.5ij3S9PQkJ0YPfVfoZ9Mzlu0m6_xQJU6JJnljm0IZOFvEGIYhbWk9Q.iDQqf4q20QVCMPMS-g64kw.uKD4LKZosgXcmKWMZtI4JwpjjGZMa0qDo8jRZsLnSRkI02Fr3trUTJMo4isr0TouSVnrHezgIRU0_jF-KCCDUA.4AwFYtD0o8JXaA2_Ha0AOw'
jwk = {'kty': 'oct', 'k': 'c4OqgE8K3OMf-9W0lxa8EGSY5eeebSnVKvfZu9AssAg'}

parts = encrypted_jwe.split('.')
if len(parts) != 5:
    print("invalid JWE")
    exit(1)
header, encrypted_key, iv, ciphertext, tag = parts

# unwrap primary key and split in authentication and encryption key
cek_encrypted = b64url_decode(encrypted_key)
cek_key = b64url_decode(jwk['k'])
cek = aes_key_unwrap(cek_key, cek_encrypted)
auth_key = cek[:16]
enc_key = cek[16:]

# authenticate
decoded_iv = b64url_decode(iv)
decoded_tag = b64url_decode(tag)
decoded_ciphertext = b64url_decode(ciphertext)
authData = header.encode('ascii') + decoded_iv + decoded_ciphertext + (len(header) * 8).to_bytes(8, 'big')
h = hmac.HMAC(auth_key, hashes.SHA256())
h.update(authData)
tag_calc = h.finalize()
auth_suceeded = tag_calc[:16] == decoded_tag # compare the first 16 bytes from the calculated tag

# on success, decrypt
if auth_suceeded:
    cipher = Cipher(algorithms.AES(enc_key), modes.CBC(decoded_iv), backend=default_backend())
    decryptor = cipher.decryptor()
    decrypted_payload = decryptor.update(decoded_ciphertext) + decryptor.finalize()
    unpadder = padding.PKCS7(128).unpadder() 
    decrypted_payload = unpadder.update(decrypted_payload) + unpadder.finalize() # unpad!
    print("decrypted Payload:", decrypted_payload.decode()) # decrypted Payload: {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
else:
    print("decrypted failed")

However, it is much more efficient to use a JOSE implementation, e.g. JWCrypto:

from jwcrypto import jwk, jwe

encrypted_jwe = 'eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.5ij3S9PQkJ0YPfVfoZ9Mzlu0m6_xQJU6JJnljm0IZOFvEGIYhbWk9Q.iDQqf4q20QVCMPMS-g64kw.uKD4LKZosgXcmKWMZtI4JwpjjGZMa0qDo8jRZsLnSRkI02Fr3trUTJMo4isr0TouSVnrHezgIRU0_jF-KCCDUA.4AwFYtD0o8JXaA2_Ha0AOw'
jwkey = {'kty': 'oct', 'k': 'c4OqgE8K3OMf-9W0lxa8EGSY5eeebSnVKvfZu9AssAg'}

jwetoken = jwe.JWE()
jwetoken.deserialize(encrypted_jwe)
jwetoken.decrypt(jwk.JWK(**jwkey))
payload = jwetoken.payload

print("with JWCrypto:", payload.decode('utf8')) # with JWCrypto: {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
Topaco
  • 40,594
  • 4
  • 35
  • 62
  • Thank you, my only goal was to extract the JWT payload from a JWE in order to view any custom claims. Of course it's mandatory to take care of the authentication part for real world applications. To be precise, I realized that in the given example the key is wrong, however I was not splitting the 32 bytes correctly for decoding. I wanted to implement things this way to try to get my hands dirty with something slightly more low-level and avoid just having a wrapper to some magic system. – docdev Aug 27 '23 at 18:22