Skip to content

Instantly share code, notes, and snippets.

@akosasante
Created September 5, 2022 18:33
Show Gist options
  • Save akosasante/79164111fe4a47204dd7eae2aa199ff0 to your computer and use it in GitHub Desktop.
Save akosasante/79164111fe4a47204dd7eae2aa199ff0 to your computer and use it in GitHub Desktop.
Teller Bank Challenge for 2022 Elixir Conf - Akosua A.

Teller Bank Challenge

Mix.install([:req, :jason, :kino])

Your Solution

username = Kino.Input.text("Username") |> Kino.render()
password = Kino.Input.text("Password")
defmodule TellerBank do
  defmodule OTPCode do
    @moduledoc """
    You can use this util module to generate your OTP
    code dynamically.
    """

    @type username() :: String.t()

    @spec generate(username) :: String.t()
    def generate(username) do
      username
      |> String.to_charlist()
      |> Enum.take(6)
      |> Enum.map(&char_to_keypad_number/1)
      |> List.to_string()
      |> String.pad_leading(6, "0")
    end

    defp char_to_keypad_number(c) when c in ~c(a b c), do: '2'
    defp char_to_keypad_number(c) when c in ~c(d e f), do: '3'
    defp char_to_keypad_number(c) when c in ~c(g h i), do: '4'
    defp char_to_keypad_number(c) when c in ~c(j k l), do: '5'
    defp char_to_keypad_number(c) when c in ~c(m n o), do: '6'
    defp char_to_keypad_number(c) when c in ~c(p q r s), do: '7'
    defp char_to_keypad_number(c) when c in ~c(t u v), do: '8'
    defp char_to_keypad_number(c) when c in ~c(w x y z), do: '9'
    defp char_to_keypad_number(_), do: '0'
  end

  ########################################################################

  defmodule ChallengeResult do
    @type t :: %__MODULE__{
            account_number: String.t(),
            balance_in_cents: integer
          }
    defstruct [:account_number, :balance_in_cents]
  end

  ########################################################################

  defmodule Client do
    alias Req.Response
    alias TellerBank.TellerApi

    @type username() :: String.t()
    @type password() :: String.t()
    @type account_id() :: String.t()

    @spec fetch(username, password) :: ChallengeResult.t()
    def fetch(username, password) do
      with {:ok, login_resp} <- TellerApi.do_login_req(username, password),
           {:ok, mfa_resp} <- TellerApi.do_mfa_req(username, login_resp),
           {:ok, %Response{body: accounts_body} = account_resp} <-
             TellerApi.do_otp_login(username, mfa_resp),
           %{"id" => account_id} =
             _first_chequing_account =
             accounts_body["accounts"]["checking"]
             |> hd(),
           {:ok, %Response{body: account_balance} = balance_resp} <-
             TellerApi.get_account_balance(
               account_id,
               username,
               account_resp
             ),
           {:ok, %Response{body: account_details}} <-
             TellerApi.get_account_details(
               account_id,
               username,
               balance_resp
             ),
           {:ok, account_number} <- TellerApi.get_account_number(accounts_body, account_details) do
        %ChallengeResult{
          account_number: account_number,
          balance_in_cents: account_balance["available"]
        }
      end
    end
  end

  ########################################################################

  defmodule TellerApi do
    alias TellerBank.OTPCode
    alias TellerBank.Client
    alias Req.Response
    require Logger
    @api_key System.get_env("API_KEY", "HowManyDevsDoesItTakeToConnectAMacbookToAProjector?")
    @device_id System.get_env("DEVICE_ID", "4ZVN6VO3ZR5F2MO3")

    @spec do_login_req(Client.username(), Client.password()) :: {:ok, Response.t()} | :error
    def do_login_req(username, password) do
      Logger.info("Logging in with auth creds...")
      post!("/login", [], %{username: username, password: password})
    end

    @spec do_mfa_req(Client.username(), Response.t()) :: {:ok, Response.t()} | :error
    def do_mfa_req(username, %Response{} = login_resp) do
      Logger.info("Starting MFA request...")

      with %Response{body: %{"mfa_required" => true} = mfa_body} = login_resp,
           # No observed difference if we use voice or sms for auth, just picking sms arbitrarily here.
           {:ok, %{sms_id: sms_id, voice_id: _voice_id}} = get_mfa_ids(mfa_body),
           {:ok, f_token} <- generate_f_token(login_resp, username),
           {:ok, request_token} <- get_header_value(login_resp.headers, "request-token") do
        headers = [f_token: f_token, request_token: request_token, teller_is_hiring: "I know!"]
        post!("/login/mfa/request", headers, %{device_id: sms_id})
      end
    end

    @spec do_otp_login(Client.username(), Response.t()) :: {:ok, Response.t()} | :error
    def do_otp_login(username, %Response{} = mfa_resp) do
      Logger.info("Logging in with OTP code...")

      with {:ok, f_token} <- generate_f_token(mfa_resp, username),
           {:ok, request_token} <- get_header_value(mfa_resp.headers, "request-token") do
        headers = [f_token: f_token, request_token: request_token, teller_is_hiring: "I know!"]
        post!("/login/mfa", headers, %{code: OTPCode.generate(username)})
      end
    end

    @spec get_account_details(Client.account_id(), Client.username(), Response.t()) ::
            {:ok, Response.t()} | :error
    def get_account_details(account_id, username, %Response{} = account_home_resp) do
      Logger.info("Fetching account details for account_id=#{account_id}")

      with {:ok, f_token} <- generate_f_token(account_home_resp, username),
           {:ok, request_token} <- get_header_value(account_home_resp.headers, "request-token") do
        headers = [f_token: f_token, request_token: request_token, teller_is_hiring: "I know!"]
        get!("/accounts/#{account_id}/details", headers)
      end
    end

    @spec get_account_balance(Client.account_id(), Client.username(), Response.t()) ::
            {:ok, Response.t()} | :error
    def get_account_balance(account_id, username, %Response{} = account_details_resp) do
      Logger.info("Fetching balance for account_id=#{account_id}")

      with {:ok, f_token} <- generate_f_token(account_details_resp, username),
           {:ok, request_token} <- get_header_value(account_details_resp.headers, "request-token") do
        headers = [f_token: f_token, request_token: request_token, teller_is_hiring: "I know!"]
        get!("/accounts/#{account_id}/balances", headers)
      end
    end

    def get_account_number(%{"enc_session_key" => encoded_session_key}, %{
          "number" => encoded_account_number
        }) do
      {:ok, %{"key" => session_key, "cipher" => cipher}} =
        get_session_key_and_cipher(encoded_session_key)

      try do
        {:ok, decrypt_hashed_value(cipher, encoded_account_number, session_key)}
      rescue
        e ->
          Logger.error("Error encountered while decoding account number: #{inspect(e)}")
          e
      end
    end

    ### private

    defp get_mfa_ids(%{"devices" => devices}) do
      sms = Enum.find(devices, fn device -> device["type"] === "SMS" end)
      voice = Enum.find(devices, fn device -> device["type"] === "VOICE" end)

      case [sms, voice] do
        [nil, nil] -> :error
        [%{"id" => sms_id}, %{"id" => voice_id}] -> {:ok, %{sms_id: sms_id, voice_id: voice_id}}
      end
    end

    defp generate_f_token(%Response{headers: headers}, username) do
      {:ok, spec} = get_header_value(headers, "f-token-spec")

      case decode_f_token_spec(spec) do
        {:ok, %{"method" => "sha256-base64-no-padding"} = decoded_spec} ->
          {:ok, last_req_id} = get_header_value(headers, "f-request-id")

          f_token =
            do_generate_f_token(decoded_spec, username, last_req_id)
            |> then(&:crypto.hash(:sha256, &1))
            |> Base.encode64(padding: false)

          {:ok, f_token}

        # becomes sha-three-two-five-six-base-thirty-two-lower-case-no-padding after Caesar Cipher decoding
        {:ok,
         %{"method" => "fun-guerr-gjb-svir-fvk-onfr-guvegl-gjb-ybjre-pnfr-ab-cnqqvat"} =
             decoded_spec} ->
          {:ok, last_req_id} = get_header_value(headers, "f-request-id")

          f_token =
            do_generate_f_token(decoded_spec, username, last_req_id)
            |> then(&:crypto.hash(:sha3_256, &1))
            |> Base.encode32(padding: false, case: :lower)

          {:ok, f_token}

        :error ->
          :error

        _ ->
          Logger.error("Got an unhandled f-token spec (check decode_f_token_spec/1)")
          :error
      end
    end

    defp decode_f_token_spec(f_token_spec_header) do
      try do
        f_token_spec_header
        |> Base.decode64(padding: false)
        |> case do
          {:ok, decoded_str} ->
            decoded_str
            |> Jason.decode()

          _ ->
            throw({:error, :could_not_decode_header})
        end
      catch
        {:error, :could_not_decode_header} ->
          :error
      end
    end

    defp do_generate_f_token(f_token_spec, username, last_req_id) do
      f_token_spec["values"]
      |> Enum.map(fn
        "username" ->
          username

        "api-key" ->
          @api_key

        "device-id" ->
          @device_id

        "last-request-id" ->
          last_req_id
      end)
      |> Enum.join(f_token_spec["separator"])
    end

    defp get_session_key_and_cipher(encoded_session_key) do
      case Base.decode64(encoded_session_key) do
        {:ok, decoded_str} -> Jason.decode(decoded_str)
        _ -> :err
      end
    end

    defp decrypt_hashed_value("128-ECB", data, encoded_key) do
      {:ok, key} = Base.decode64(encoded_key)
      {:ok, ciphertext} = Base.decode64(data)
      <<iv::binary-16, ciphertext::binary>> = ciphertext
      decrypted_text = :crypto.crypto_one_time(:aes_128_ecb, key, iv, ciphertext, false)

      unpad_pkcs7(decrypted_text)
    end

    defp unpad_pkcs7(data) do
      to_remove = :binary.last(data)
      :binary.part(data, 0, byte_size(data) - to_remove)
    end

    defp post!(url, headers, body) do
      case base_req()
           |> Req.post!(url: url, headers: headers, json: body) do
        %Response{status: 200} = successful_resp ->
          {:ok, successful_resp}

        %Response{} = resp ->
          Logger.error(
            "Non-successful HTTP code when running POST request to path=#{url}. Status=#{resp.status} Body=#{inspect(resp.body)}"
          )

          :error

        e ->
          Logger.error("Error when running POST request to path=#{url}. Error=#{inspect(e)}")
          :error
      end
    end

    defp get!(url, headers) do
      case base_req()
           |> Req.get!(url: url, headers: headers) do
        %Response{status: 200} = successful_resp ->
          {:ok, successful_resp}

        %Response{} = resp ->
          Logger.error(
            "Non-successful HTTP code when running GET request to path=#{url}. Status=#{resp.status} Body=#{inspect(resp.body)}"
          )

          :error

        e ->
          Logger.error("Error when running GET request to path=#{url}. Error=#{inspect(e)}")
          :error
      end
    end

    defp get_header_value(headers, key) do
      headers
      |> List.keyfind(key, 0)
      |> case do
        nil ->
          Logger.error("Could not find #{key} in #{inspect(headers)}")
          :error

        {_k, v} ->
          {:ok, v}
      end
    end

    defp base_req() do
      headers = [
        api_key: @api_key,
        device_id: @device_id,
        content_type: "application/json",
        accept: "application/json"
      ]

      Req.new(
        base_url: "https://challenge.teller.engineering",
        headers: headers,
        user_agent: "Teller Bank iOS 1.1"
      )
    end
  end
end

username = Kino.Input.read(username)
password = Kino.Input.read(password)

TellerBank.Client.fetch(username, password)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment