Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Teller Bank Challenge

elixirconf--2022/teller-challenge.livemd

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
    @type username() :: String.t()
    @type password() :: String.t()

    @base_url "https://challenge.teller.engineering"
    @api_key "HowManyDevsDoesItTakeToConnectAMacbookToAProjector?"
    @device_id "LZWDG7DMF72C3LP3"
    @default_headers [
      {"user-agent", "Teller Bank iOS 1.1"},
      {"api-key", @api_key},
      {"device-id", @device_id},
      {"content-type", "application/json"},
      {"accept", "application/json"}
    ]

    @spec fetch(username, password) :: ChallengeResult.t()
    def fetch(username, password) do
      sms_code = TellerBank.OTPCode.generate(username)
      login_response = login_request(username, password)
      mfa_response = mfa_request(login_response, username)

      logged_in_response = mfa_login(mfa_response, username, sms_code)
      account_id = get_account_id(logged_in_response)
      enc_session_key = get_session_key(logged_in_response)

      balance_response = get_balances(logged_in_response, account_id, username)
      balance_in_cents = get_account_balance(balance_response)
      details_response = get_account_details(balance_response, account_id, username)
      account_number = get_account_number(details_response, enc_session_key)

      %ChallengeResult{account_number: account_number, balance_in_cents: balance_in_cents}
    end

    defp login_request(username, password) do
      url = @base_url <> "/login"
      body = %{password: password, username: username}
      # IO.inspect(url, label: "url")
      # IO.inspect(@default_headers, label: "headers")
      # IO.inspect(username, label: "username")
      # IO.inspect(password, label: "password")
      # IO.inspect(body, label: "body")
      request = Req.new()
      Req.post!(request, url: url, headers: @default_headers, json: body)
    end

    defp mfa_request(response, username) do
      url = @base_url <> "/login/mfa/request"
      request_headers = generate_headers(response.headers, username)

      %{
        "devices" => [
          %{
            "id" => device_id,
            "mask" => "***8056",
            "type" => "SMS"
          },
          _
        ]
      } = response.body

      body = %{device_id: device_id}
      # IO.inspect(request_headers, label: "headers")
      # IO.inspect(body, label: "body")
      request = Req.new()
      Req.post!(request, url: url, headers: request_headers, json: body)
    end

    defp mfa_login(response, username, sms_code) do
      url = @base_url <> "/login/mfa"
      request_headers = generate_headers(response.headers, username)
      body = %{code: sms_code}
      # IO.inspect(request_headers, label: "headers")
      # IO.inspect(body, label: "body")
      request = Req.new()
      Req.post!(request, url: url, headers: request_headers, json: body)
    end

    defp get_balances(response, account_id, username) do
      url = @base_url <> "/accounts/#{account_id}/balances"
      request_headers = generate_headers(response.headers, username)
      request = Req.new()
      Req.get!(request, url: url, headers: request_headers)
    end

    defp get_account_details(response, account_id, username) do
      url = @base_url <> "/accounts/#{account_id}/details"
      request_headers = generate_headers(response.headers, username)
      request = Req.new()
      Req.get!(request, url: url, headers: request_headers)
    end

    defp get_account_id(response) do
      %{
        "accounts" => %{
          "checking" => [
            %{
              "id" => account_id
            }
          ]
        }
      } = response.body

      account_id
    end

    defp get_account_balance(response) do
      balance_available = Map.get(response.body, "available", 0)
      # IO.inspect(response, label: "response")
      # IO.inspect(balance_available, label: "balance")
      balance_available
    end

    defp get_account_number(response, key) do
      encoded_number = Map.get(response.body, "number", "")
      {:ok, ciphertext} = Base.decode64(encoded_number)
      <> = ciphertext
      decrypted = :crypto.crypto_one_time(:aes_128_ecb, key, iv, ciphertext, false)
      last = :binary.last(decrypted)
      :binary.part(decrypted, 0, byte_size(decrypted) - last)
    end

    defp get_session_key(response) do
      session_key = Map.get(response.body, "enc_session_key", "")
      # IO.inspect(response, label: "response")
      # IO.inspect(session_key, label: "session_key")
      # Decoded as %{"cipher" => "128-ECB", "key" => "S9esd7EF8/aeRt27btOKww=="}
      session_key
      |> Base.decode64!()
      |> Jason.decode!()
      # |> IO.inspect(label: "decoded_session_key")
      |> Map.fetch!("key")
      |> Base.decode64!()
    end

    defp generate_headers(headers, username) do
      response_headers = Map.new(headers)
      last_request_id = Map.get(response_headers, "f-request-id", "")
      request_token = Map.get(response_headers, "request-token", "")
      f_token_spec = Map.get(response_headers, "f-token-spec", "")
      f_token = f_token_spec |> generate_token(username, last_request_id)

      [
        {"teller-is-hiring", "I know!"},
        {"request-token", request_token},
        {"f-token", f_token}
      ] ++ @default_headers
    end

    defp generate_token(spec, username, last_request_id) do
      %{"separator" => separator, "values" => values} =
        spec |> Base.decode64!(padding: false) |> Jason.decode!()

      # IO.inspect(separator, label: "separator")
      # IO.inspect(values, label: "values")
      encoded =
        Enum.map(values, fn
          "last-request-id" -> last_request_id
          "username" -> username
          "device-id" -> @device_id
          "api-key" -> @api_key
        end)
        |> Enum.join(separator)

      # IO.inspect(encoded, label: "encoded")

      :sha3_256
      |> :crypto.hash(encoded)
      |> Base.encode32(case: :lower, padding: false)
    end
  end
end

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

TellerBank.Client.fetch(username, password)