Need help with basic crypto in Erlang/Elixir: Decryption Error: Failed to decrypt:

I have been battling with some code:

I have 2 variants I have been testing:

  def ds4 do
    try do
      private_key_pem = File.read!("priv/flows/private_unencrypted.pem")

      # Decode PEM key explicitly
      private_key_entry = :public_key.pem_decode(private_key_pem) |> hd()
      IO.inspect(private_key_entry, label: "Private Key Entry")

      # Correctly decode the private key
      case private_key_entry do
        {:RSAPrivateKey, rsa_key} ->
          IO.inspect(rsa_key, label: "RSA Private Key Structure")

          # Decrypt AES key
          encrypted_aes_key =
            Base.decode64!(
              "i/j13zUEy37M00eFJYchyIyk+HIlmLs8X6gmJLO9UfiljUmQCX1kYVPYfVRbS+5moWKtkIc0K/KG59CObmT8sMbhSXFEbnCKL7bbLka80NC5lqoill4LKrAtOaWJ/Zxbw7YjWS1+zinkIZZCbJ0OGZjH24XVgx3pVx5PYK53JDSYt6kUo1Kt15uc5M7zne1/T46c5YCw2BnSGDvXX+74W7T8xo+dTl6krOzFGuxNXFOYtG3coFK3Ad4eteMIhlsxpoRjFggfIWv9VuA7TT9AOQ9mdF2juN98Hu0hm3kSi5IHHGagTq+UwrspnMl76kw+GZXinjQNSI2ZV8yYYHxQ=="
            )

          decrypted_aes_key =
            :crypto.private_decrypt(:rsa, encrypted_aes_key, rsa_key,
              rsa_padding: :rsa_pkcs1_oaep_padding,
              oaep_hash: :sha256
            )

          IO.inspect(decrypted_aes_key, label: "Decrypted AES Key")
          decrypted_aes_key

        other ->
          IO.inspect(other, label: "Unexpected Private Key Structure")
          {:error, :unexpected_private_key_structure}
      end
    rescue
      e ->
        IO.inspect("Failed to decrypt: #{inspect({e, __STACKTRACE__})}",
          label: "Decryption Error"
        )

        {:error, e}
    end
  end

  def ds5 do
    try do
      private_key_pem = File.read!("priv/flows/private_unencrypted.pem")

      # Remove PEM headers and decode base64
      private_key_der =
        private_key_pem
        |> String.replace(~r/-----BEGIN RSA PRIVATE KEY-----/, "")
        |> String.replace(~r/-----END RSA PRIVATE KEY-----/, "")
        |> Base.decode64!()

      # Decode DER-encoded private key
      {:Ok, private_key, _} = :public_key.der_decode(private_key_der, :RSAPrivateKey)

      IO.inspect(private_key, label: "RSA Private Key Structure")

      m = %{
        encrypted_aes_key:
          "i/j13zUEy37M00eFJYchyIyk+HIlmLs8X6gmJLO9UfiljUmQCX1kYVPYfVRbS+5moWKtkIc0K/KG59CObmT8sMbhSXFEbnCKL7bbLka80NC5lqoill4LKrAtOaWJ/Zxbw7YjWS1+zinkIZZCbJ0OGZjH24XVgx3pVx5PYK53JDSYt6T6kUo1Kt15uc5M7zne1/T46c5YCw2BnSGDvXX+74W7T8xo+dTl6krOzFGuxNXFOYtG3coFK3Ad4eteMIhlsxpoRjFggfIWv9VuA7TT9AOQ9mdF2juN98Hu0hm3kSi5IHHGagTq+UwrspnMl76kw+GZXinjQNSI2ZV8yYYHxQ==",
        encrypted_flow_data:
          "esYLI3l/ZREnkg3EDoipH/TeF8fL6goV1GELcV/Jv+WWVkB/5JcJ5NRoFzfnDZrTKA==",
        initial_vector: "65SPDGDmgHlG4MiRzy5abQ=="
      }

      # Decrypt AES key
      encrypted_aes_key = Base.decode64!(m.encrypted_aes_key)

      decrypted_aes_key =
        :crypto.private_decrypt(:rsa, encrypted_aes_key, private_key,
          rsa_padding: :rsa_pkcs1_oaep_padding,
          oaep_hash: :sha256
        )

      IO.inspect(decrypted_aes_key, label: "Decrypted AES Key")
      decrypted_aes_key
    rescue
      e ->
        IO.inspect("Failed to decrypt: #{inspect({e, __STACKTRACE__})}",
          label: "Decryption Error"
        )

        {:error, e}
    end
  end

I’m at a loss for the errors I keep getting:

 FL.ds4
Private Key Entry: {:PrivateKeyInfo,
 <<48, 130, 4, 190, 2, 1, 0, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1,
   5, 0, 4, 130, 4, 168, 48, 130, 4, 164, 2, 1, 0, 2, 130, 1, 1, 0, 162, 144,
   53, 31, 174, 226, 155, 127, 205, 227, ...>>, :not_encrypted}
Unexpected Private Key Structure: {:PrivateKeyInfo,
 <<48, 130, 4, 190, 2, 1, 0, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1,
   5, 0, 4, 130, 4, 168, 48, 130, 4, 164, 2, 1, 0, 2, 130, 1, 1, 0, 162, 144,
   53, 31, 174, 226, 155, 127, 205, 227, ...>>, :not_encrypted}
{:error, :unexpected_private_key_structure}
15:36:33.174 DB "get_inactive_agents"
15:36:33.193 DB "summarize_1"
15:36:33.227 DB "summarize_2"
15:36:33.423 {:sse, "stats-refresh"}
 FL.ds5
Decryption Error: "Failed to decrypt: {%ArgumentError{message: \"non-alphabet character found: \\\"-\\\" (byte 45)\"}, [{Base, :bad_character!, 1, [file: ~c\"lib/base.ex\", line: 137]}, {Base, :\"-decode64base!/2-lbc$^0/2-0-\", 2, [file: ~c\"lib/base.ex\", line: 642]}, {Base, :decode64base!, 2, [file: ~c\"lib/base.ex\", line: 640]}, {FL, :ds5, 0, [file: ~c\"lib/chatflow/fl.ex\", line: 222]}, {:elixir, :eval_external_handler, 3, [file: ~c\"src/elixir.erl\", line: 386]}, {:erl_eval, :do_apply, 7, [file: ~c\"erl_eval.erl\", line: 750]}, {:elixir, :eval_forms, 4, [file: ~c\"src/elixir.erl\", line: 364]}, {Module.ParallelChecker, :verify, 1, [file: ~c\"lib/module/parallel_checker.ex\", line: 112]}]}"
{:error, %ArgumentError{message: "non-alphabet character found: \"-\" (byte 45)"}}

Any help and insight would be appreciated :palms_up_together:

Also, i have updated the question to include the requirements from META:

Implementing Endpoints for Flows - WhatsApp Flows (facebook.com)

Request Decryption and Encryption
The incoming request body is encrypted, you need to decrypt it first, then you need to encrypt the server response before returning it to the client.

You can find code examples of decryption/encryption in various programming languages in the Code Examples section.

For data_api_version “3.0” you should follow below instructions to decrypt request payload:

extract payload encryption key from encrypted_aes_key field:
decode base64-encoded field content to byte array;
decrypt resulting byte array with the private key corresponding to the uploaded public key using RSA/ECB/OAEPWithSHA-256AndMGF1Padding algorithm with SHA256 as a hash function for MGF1;
as a result, you’ll get a 128-bit payload encryption key.
decrypt request payload from encrypted_flow_data field:
decode base64-encoded field content to get encrypted byte array;
decrypt encrypted byte array using AES-GCM algorithm, payload encryption key and initialization vector passed in initial_vector field (which is base64-encoded as well and should be decoded first). Note that the 128-bit authentication tag for the AES-GCM algorithm is appended to the end of the encrypted array.
result of above step is UTF-8 encoded clear request payload.
For data_api_version “3.0” you should follow below instructions to encrypt the response:

encode response payload string to response byte array using UTF-8;
prepare initialization vector for response encryption by inverting all bits of the initialization vector used for request payload encryption;
encrypt response byte array using AES-GCM algorithm with the following parameters:
secret key - payload encryption key from request decryption stage;
initialization vector for response encryption from above step;
empty AAD (additional authentication data) - many libraries assume this by default, check the documentation of the library in use;
128-bit (16 byte) length for authentication tag - many libraries assume this by default, check the documentation of the library in use;
append authentication tag generated during encryption to the end of the encryption result;
encode the whole output as base64 string and send it in the HTTP response body as plain text.

The first one is because the PEM file you’re supplying has a PKCS #8 PrivateKeyInfo in it (see also RFC5208) which can contain an RSAPrivateKey, rather than the bare RSAPrivateKey the code expects.

The second one is harder to guess, but I suspect there’s some other -----BEGIN header that’s tripping up the base64 decoder. Based on not printing RSA Private Key Structure, I don’t think ds5 is even making it to any cryptographic calls.

If you want to use :public_key to decode PEM-encoded keys you need to use :public_key.pem_entry_decode/1 on the data returned by :public_key.pem_decode/1.

If you want to extract the DER entry from the PEM yourself you’re going to have to do a bit more work to remove extra whitespace (e.g. newlines).

P.S. you can save yourself a lot of trouble by using x509… :slight_smile:

3 Likes

thanks for the reference, i was not aware of x509 library

Thanks for the reply.

these were the steps META provided for key creation:

openssl genrsa -des3 -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private_unencrypted.pem

Using those steps, I was able to recreate your observations and make some additional ones:

  • the generated private_unencrypted.pem file has a -----BEGIN PRIVATE KEY----- header, not the one that the code in ds5 was expecting to trim off
  • the RSAPrivateKey details are available by also using pem_entry_decode as @voltone suggested:
    File.read!("path_to/private_unencrypted.pem")
    |> :public_key.pem_decode()
    |> hd()
    |> :public_key.pem_entry_decode()
    
    # result - 11-element tuple
    {:RSAPrivateKey, :"two-prime", <giant number>, 65537, <giant number>, <giant number>, <giant number>, ..., :asn1_NOVALUE}
    
2 Likes

Thanks for this, ill review.

But it begs the question,
Take meta’s requiremets, for interfacing with WhatsApp flows, it’s well spelt out and we see how its done in 3 other mainstream languages.

Ive been at this for 2 days in elixir and erlang with no success.

Not good.

I’ve felt the same in the past. Crypto seems to sit at a different level of abstraction, which makes adapting code examples not really enjoyable.

2 Likes

But we we should have standard reference implementations for these pretty common use-cases.

Everyone says dont do your own cryptography…, okay, but give us copy- paste code to use then…

AI at this point is just generating broken code that doesn’t help either.

These things have complete implementations in other languages.

Not a good look that we can’t get this done easily and, more importantly - correctly in Elixir or Erlang

For this to be the case, someone has to do it first. @voltone’s x509 library and his contributions to OTP really moved the needle, but in the short time I’ve dealt with decoding certificates too, this is absolutely not an easy job, as there is a lot of edge-cases.

Speaking generally, you can always call openssl functionality using System.cmd/1 or Port from your app, this has always saved me in the cases where I couldn’t figure out how to do it myself in elixir/erlang.

2 Likes

Calling shell commands Might not be ideal for many use-caes.

WhatsApp is high traffic solution. Wouldn’t want this to become an avoidable bottleneck.

The steps, and specifications are clearly spelt out by Meta.

@voltone has pointed out x509, I’ll look at that now.

Its funny how this is a breeze in JavaScript, and nodejs.

There is a python reference done by meta themselves.

Calling that implementing via a port would be one sad way to do it…

Those are not specifications, those are just words on a web page, specifications should follow conventions and adhere to strict rules. You want a specification, read a ISO standard and you will see how one should look like.

You should use the right tools for the job, if those ecosystems work much better, then use them. I don’t find this even close as an argument to blame elixir/erlang ecosystem that they lack some certificate format used by a company, especially when those are homebrewed and not industry standard.

Thinking about what ifs will get you nowhere. You can always start with System.cmd/1, then if that becomes a bottleneck, you can refactor to NIFs that would be implemented in C/rust and use the dev library for openssl.

1 Like

All your points are well taken sir.

it’s just the frustration of loosing time and sleep on parts of a project that you expected would be straight forward.

Forgive my rant please :pray:

2 Likes

Cryptography stuff from scratch is very hard, even if you know the theory, you might still fail to implement it correctly.

If there isn’t a ready solution available, I would always consider first using Ports or another mechanism to borrow the implementation from another ecosystem, as I’ve fallen myself into this trap of spending countless days debugging myself.

3 Likes

I have used the X509 library with some success:

 def run do
    private_unencrypted_pem = File.read!("priv/flows/private_unencrypted.pem")
    [private_key] = X509.from_pem(private_unencrypted_pem)

    IO.inspect(private_key, label: "private_key")

    m = %{
      encrypted_aes_key:
        "i/j13zUEy37M00eFJYchyIyk+HIlmLs8X6gmJLO9UfiljUmQCX1kYVPYfVRbS+5moWKtkIc0K/KG59CObmT8sMbhSXFEbnCKL7bbLka80NC5lqoill4LKrAtOaWJ/Zxbw7YjWS1+zinkIZZCbJ0OGZjH24XVgx3pVx5PYK53JDSYt6T6kUo1Kt15uc5M7zne1/T46c5YCw2BnSGDvXX+74W7T8xo+dTl6krOzFGuxNXFOYtG3coFK3Ad4eteMIhlsxpoRjFggfIWv9VuA7TT9AOQ9mdF2juN98Hu0hm3kSi5IHHGagTq+UwrspnMl76kw+GZXinjQNSI2ZV8yYYHxQ==",
      encrypted_flow_data: "esYLI3l/ZREnkg3EDoipH/TeF8fL6goV1GELcV/Jv+WWVkB/5JcJ5NRoFzfnDZrTKA==",
      initial_vector: "65SPDGDmgHlG4MiRzy5abQ=="
    }

    {:ok, cipherText} = Base.decode64(m.encrypted_aes_key)

    :public_key.decrypt_private(cipherText, private_key)
  end

I now get the following:

private_key: {:RSAPrivateKey, :"two-prime",
 20521716780355580191550654660427168801033017999979128058505635221350308656209136095559482889939248891214276985696478264628555013673442179387450639676235923276763753945178489184101412002814944057148911035896122839963913153231089553623783047356337013294406425764168745177227849841528233456796700583848965253978778841370892407240172397435296874412542326417557014040885255496065392387262072486147756337139772431797705036236225154549695224561441211981922041229894695275663304593054518352707833166848660424321605184883485794491793623948711036778133900858257107938104282992179043651995979330716543636145006439954526445240763,
 65537,
 6101372529490646200350405207110844013124316885569881291789100848192788259551627581701581154316893734002322154909377588023366868203106960424866498528944824527331762906782471912846422827942218669660596785547644132882140589143655949347077416997089074935403042647890931836646240355252416633438877441388789355230952210159381055825921275221081940869473469830689864917813711227959475309631843238750002918360166052964140819289834990306960827834019082104583666169035743860983433438292667142785107052016117789083159263948861977159066001010422854460585806501949831365120461251443129509716016262603213687243268156580468396104873,
 151190366275169636659156710850755871081855237513754653133885676881553005863138306361469825928422763931411709054490695520372670862263762193129823956501853620635206291633382027347117489353456935897153558861010747074354736527517171778666319726670165784090610050738948575379033685918723403262535625181590972124391,
 135734288407011498129478129421886631648677888079098386018899059790453014073190770088624655395583559931425640561364135203441528615363819617475833915933485975414483867477768271128867544676095890623937965185083854342062520563606869218948617789616908888635193647838433353329362488409479692935739217245906616276493,
 111084097790285920081964599247533404097433410085330638535527459499222429136546313195866381709651234094104652553394814695469503446293310299885366787516628096202548993007470762787713831077828682687943271225039130462552897954941588041661056006204461950280468363873565341740562278474119494543503905026839319616913,
 40289288682448001481800174735553361390691227196891845876766458795850931271888857447457396911825182589163724092348086151525825963267656179238558328374110848524313817752491358748033329975191012104726570284655022636314482086818811158829477422851484331150654165302059050953142323985376328893280665777875420089821,
 102567540933109511029738225524188193914715584345455927873497835038050930695268722174185678374967621653339377683257747663760985231626325445842500766475654732178732141643253908789737635412175714081538765312270435649325915057332637228855149308606011583122920805142192972378445966624673177556168108118988753484264,
 :asn1_NOVALUE}
** (ErlangError) Erlang error: {:error, {~c"pkey.c", 1179}, ~c"Couldn't get the result"}
    (crypto 5.4.2.1) crypto.erl:1607: :crypto.pkey_crypt(:rsa, <<139, 248, 245, 223, 53, 4, 203, 126, 204, 211, 71, 133, 37, 135, 33, 200, 140, 164, 248, 114, 37, 152, 187, 60, 95, 168, 38, 36, 179, 189, 81, 248, 165, 141, 73, 144, 9, 125, 100, 97, 83, 216, 125, 84, 91, 75, 238, 102, 161, 98, ...>>, [<<1, 0, 1>>, <<162, 144, 53, 31, 174, 226, 155, 127, 205, 227, 111, 224, 86, 190, 49, 167, 12, 2, 155, 192, 252, 33, 242, 162, 138, 61, 227, 59, 7, 48, 48, 20, 57, 0, 223, 186, 106, 138, 48, 116, 103, 75, 97, 158, 230, 71, 188, 103, ...>>, <<48, 85, 9, 198, 98, 187, 179, 25, 219, 127, 252, 122, 73, 176, 0, 153, 54, 227, 235, 165, 4, 17, 159, 207, 29, 164, 234, 211, 88, 254, 197, 188, 173, 122, 10, 10, 165, 180, 114, 85, 153, 132, 123, 0, 192, 92, 161, ...>>, <<215, 77, 94, 212, 206, 182, 111, 103, 65, 125, 200, 200, 28, 156, 156, 158, 211, 17, 140, 199, 66, 152, 42, 62, 176, 96, 177, 6, 205, 145, 161, 188, 29, 147, 72, 59, 180, 53, 91, 214, 148, 27, 207, 175, 10, 249, ...>>, <<193, 74, 195, 80, 188, 0, 232, 17, 152, 23, 243, 27, 74, 196, 70, 183, 8, 185, 130, 181, 190, 209, 161, 151, 220, 2, 222, 171, 94, 211, 211, 140, 248, 255, 100, 188, 191, 84, 120, 67, 246, 255, 134, 70, 135, ...>>, <<158, 48, 98, 180, 201, 12, 145, 221, 179, 232, 207, 84, 248, 112, 185, 66, 152, 162, 20, 101, 115, 48, 138, 25, 117, 78, 21, 195, 129, 214, 182, 150, 152, 196, 86, 22, 71, 217, 204, 152, 100, 73, 234, 214, ...>>, <<57, 95, 180, 187, 249, 34, 23, 192, 105, 49, 234, 178, 86, 46, 14, 131, 115, 238, 132, 178, 231, 210, 23, 178, 24, 215, 45, 59, 22, 233, 175, 104, 175, 220, 55, 13, 163, 137, 50, 45, 146, 90, 100, ...>>, <<146, 15, 159, 112, 112, 202, 238, 214, 204, 68, 134, 252, 132, 230, 56, 165, 213, 120, 240, 196, 0, 246, 98, 29, 202, 201, 7, 158, 79, 180, 183, 93, 110, 114, 198, 212, 240, 178, 71, 223, 222, 158, ...>>], [rsa_padding: :rsa_pkcs1_padding], true, false)
    iex:10: (file)

Assuming this: Implementing Endpoints for Flows - WhatsApp Flows - Dokumentation - Meta for Developers is the original instructions, it says:

using RSA/ECB/OAEPWithSHA-256AndMGF1Padding algorithm with SHA256 as a hash function for MGF1; being the important part in this case.

private_decrypt defaults to RSA PCKS1 padding (which should no longer be used as it is not secure)

I’m not sure erlang crypto module supports RSA/ECB/OAEPWIthSHA-256andMGF1Padding mode.

You can try with:
:public_key.decrypt_private(cipher, key, [rsa_padding: :rsa_pkcs1_oaep_padding]) but I am pretty sure that uses SHA1 instead of SHA256.

In addition public_key:decrypt_private2/3 and the equivalent in crypto module are deprecated. I have not found what they are replaced with though. It seems that the original intent was not for encrypt/decrypt but for signing and verification. The docs suggest that they have been replaced with :public_key.sign/verify which will not help you much.

Might give you additional options to try.

We used erlang for a crypto heavy application but we used a port (Ports — Erlang System Documentation v27.0.1) to do all the asymmetric crypto operations rather than the built-in and it performed very well with stable latency compared to our java and golang equivalent apps.

4 Likes

Thanks for the explanations. The port you used was to connect to which external program?

Im going to see if these crypto csn be done in rust, then maybe an NIF might be the way foreword.

Also using openSsl via cmd was equally suggested.

Meta provides ready to use code in PHP, django java and nodejs

Interop via a port might indeed be the quickest solution.

We wrote a custom C application which used openssl to do the crypto. We found a port worked better than a NIF in terms of predictable latency but this was a long way back when dirty NIFs were just experimental.

Unfortunately the port code doesn’t compile any longer as erlang has changed a bit since then but our port code was only around 200 lines of code.

It was just a slight enhancement of the code found here: Ports — Erlang System Documentation v27.0.1

openssl via cmd would probably be the fastest to just get something going. You can then revisit if needed.

Can you link the code?

1 Like

here is the code from MEta: Implementing Endpoints for Flows - WhatsApp Flows (facebook.com)

here is the full WhatsApp flows endpoint example from meta on GitHub. it is done in nodejs

WhatsApp-Flows-Tools/examples/endpoint/nodejs/basic/src/encryption.js at main · WhatsApp/WhatsApp-Flows-Tools (github.com)

The implementation we need is in examples/endpoint/nodejs/basic/src/encryption.js

Here is the call to nodejs crypto module.

    decryptedAesKey = crypto.privateDecrypt(
      {
        key: privateKey,
        padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
        oaepHash: "sha256",
      },

As you can see they are setting the OAEP has to sha256.

I’ve had a look at the OTP code and it looks like there is no way to do this with decode_private which uses an older openssl API which is deprecated and should not be used (and the older openssl library doesn’t support sha256 either).

To me it looks like erlang/OTP does not ship with any support to do what is required.

1 Like