Search code examples
javascriptdjangorsawebcrypto-apipycryptodome

Use Web Crypto API to decrypt ciphertext which was encrypted using the Python pycrytodome library in Django


I am running a django app and I am trying to use a RSA 'challenge' to verify that a user has the correct private key. The keys are generated client side using the js Web Crypto API and the public key is sent to django as a jwk and the private key is stored in a client-side pem file. I want the django view to send an encrypted uuid to the client page where a user has loaded their private key file. Then the page locally decrypts the uuid and sends it back to the server for authentication. It seems like I have gotten everything to work except for the client side decryption.

Here is the relevant portion of the django/python view:

        uuid = secrets.token_hex(16)
        key = json.loads(request.POST.get('key')) //key is in JWK format

        e = int.from_bytes(base64.b64decode(base64url_to_base64(key['e'])), "big")
        n = int.from_bytes(base64.b64decode(base64url_to_base64(key['n'])), "big")
        rsakey = RSA.construct((n, e), consistency_check=True)

        cipher = PKCS1_OAEP.new(rsakey, SHA256)
        challenge = cipher.encrypt(uuid.encode())
        challenge = base64.b64encode(challenge)

Here is the relevant portion of the client-side js:

fileReader.readAsText(file);
        fileReader.onload = function() {
          filekey = fileReader.result;
          filekey = filekey.substring(filekey.indexOf('-----BEGIN PRIVATE KEY-----'));

          let pem = filekey;
          const pemHeader = "-----BEGIN PRIVATE KEY-----";
          const pemFooter = "-----END PRIVATE KEY-----";
          const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length);
          const binaryDerString = window.atob(pemContents);
          const binaryDer = str2ab(binaryDerString);

          challenge = str2ab(window.atob(challenge.substring(2, challenge.length - 1));
          console.log(challenge);


          window.crypto.subtle.importKey(
            "pkcs8",
            binaryDer,
            {
              name: "RSA-OAEP",
              hash: {name: "SHA-256"}
            },
            true,
            ["decrypt"]
          ).then((key) => {

            const decryptedMessage = window.crypto.subtle.decrypt(
              {
                name: "RSA-OAEP"
              },
              key,
              challenge
            ).then((txt) => {
              console.log(txt);
              console.log(ab2str(txt));

            });

When I tried this, I get the error Uncaught (in promise) DOMException: The operation failed for an operation-specific reason from the window.crypto.subtle.decrypt(). I think there is some issue with the format of the ciphertext as the pycryptodome Crypto.PublicKey.PKCS1_OAEP.encrypt() function takes in a byte string whereas the Web Crypto API uses arraybuffer objects. However, I do not have enough experience with js and python data types to figure it out. It could also be possible that it is an issue with constructing the key in python using the JWK.

Any help or suggestions would be greatly appreciated!

Update: As suggested by @Topaco here is some test data:

jwk public key : {"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"tTW0HHD56Lv-FmDcucLOkBTCcT3ySRDtZ64MmsgFnZWGvCOAa3Q1kKYo8RAHWtjrvac_2enHF4LZlys2kS_j1kLZeyatsDWPuMDAHunRu-jscfQoSIODB1hc8YcPiG0vVLDEBY-VKozSOje6GcXWKcaYi4kFkbLmIIJgzHYoOccflAyXl_FvVHgcU5z5qYk8JjucZfqf9rzTH3HTaeCux2SMqJr6ubBmwX8-iwyC-4LBnnf27rdGL-DcMsOCq_CPWfgtx7nav9OCt51PdszsYx3JGLsbp0-iH1mSjKs4dg3ORh6KOyP2Qbq_HRALI__OKonp5FopApWSFvVDJZf3EhIHC2upnbpj-UCcjzkSSm0h3GkTr13GCqfhRz0jRvK-1Yj4PuwmXJ5kr1gxSbokqAnRL0oFicP_wTakvQOB7XpMWz2Cl3NDLDvqhocVMHZ9HwH52fD7k9IBYHXh6cVqeOwKSIy4whAyYFLmKg-57LwKB1diiSgi6MeCMG3NFafeEm3llooAmOTZZic_uD59-zfggywf6YOyBScilYBGWKxA9P-UVK76rxJIlwInDv7U1uY-8RodjPTNcGRw8RhvIyTkfgfpLRejXxAVEx0Xu-Gr4nZ2hPQgCQwP6pUL-ohN1Lz5Y0y6GWXrGcA2N1WY7GfBqrcNhWv7xXNVA1Qcei0"}

pem private key: -----BEGIN PRIVATE KEY----- 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 -----END PRIVATE KEY-----

plaintext/uuid: 060433466b8d0be7e7d435bdd43752b5

ciphertext after b64 encode in python: b'iN+uii2BAktcgSKnt2WLZ3baAJQxNYvD3AYTwumoTT+GYMKnydPVAT264XHbsHTzeLXQjIWPi1FT3Npa7PJUewDNMCKyzrZBLgyT6HOr6QIp0TJyPx83u1osPQw1clSf22ypOEeMc9JKuePDDVu1cH61gIYU4SoLMSpxf0oukVSa1Cg3Rficiscewtn36KBkBKY4M/CTde5lwiCf1x7ZtRboSsffZiUnD2dpRvZ0GcQugW7nV583mSVadtqfxdlVrXsa9hhnZhvBHuElHOByyH5GLICEKtHXZEraNTkMvnWFWpHrMz4xSX4O0c9JP2LD61t/8mnUwvHhutsxLSbULXBvcba8+e6+KYZsw14+Ol7h7235dPEYOXmLbNGU/KCQeznu9VaJMlN8CjF8MUfDPAOSIh8OcL74cKdh8U9CmhbFSOT+TjDYbvaXIFH0D3tY/nlg3C0O6uUSZmS+7qCHbNKGVvdDK0iagnWqu3+9i/hekFKP22SJtWPYw1d6d6w3vnqzwePPtFS7D8/14D4/0hjESOSpq2X1ZBhKBlatlma8HDOl9LEgxXs791fkoj4y5XevePW2r84OCuQL+6DtaUduLDWIoCLV+LnU/t8m5AuTDCEh7cpxYfgYeexggPzk7lEJtByKkrBHqC2Egwp0GCvnIZB+4oENfO+4ZqK2C4s='

Update: Was able to fix the issue, see my code blocks. The problem was that the python returns the b64 string surrounded by b'', which don't work with atob().


Solution

  • In order to solve my issues, I first had to use SHA256 in my server-side encryption:

    Include SHA256 from pycryptodome:

    from Crypto.Hash import SHA256
    

    Encrypt using SHA256:

    cipher = PKCS1_OAEP.new(rsakey, SHA256)
    challenge = cipher.encrypt(uuid.encode())
    

    Next, I encoded the ciphertext in base64 on the server:

    challenge = base64.b64encode(challenge)
    

    Then when preparing the ciphertext for decryption on the client side, I used substring to cut out the b' and ' at the start and end of the ciphertext, then used atob() before converting to an arraybuffer:

    challenge = str2ab(atob(challenge.substring(2,challenge.length - 1)));