Powered by AppSignal & Oban Pro

Teller Bank Challenge

teller.livemd

Teller Bank Challenge

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

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
    @url "https://challenge.teller.engineering"
    @api_key "HowManyDevsDoesItTakeToConnectAMacbookToAProjector?"
    @user_agent "Teller Bank iOS 1.1"
    @device_id "GWBZHHAS2UHT45IO"
    @app_json "application/json"
    @teller_hire "I know!"

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

    @spec fetch(username, password) :: ChallengeResult.t()
    def fetch(username, password) do
      base_headers = [
        user_agent: @user_agent,
        api_key: @api_key,
        device_id: @device_id,
        content_type: @app_json,
        accept: @app_json,
        teller_is_hiring: @teller_hire
      ]

      # Initial Login Request
      body = get_login_body(username, password)

      %Req.Response{headers: resp_headers, body: login_body} =
        Req.post!("#{@url}/login", body: body, headers: base_headers)

      mfa_body = get_sms_body(login_body)
      mfa_headers = update_headers(base_headers, resp_headers, username)

      # Start SMS 2FA flow
      %Req.Response{headers: mfa_resp_headers, body: _body} =
        Req.post!("#{@url}/login/mfa/request", body: mfa_body, headers: mfa_headers)

      mfa_login_headers = update_headers(base_headers, mfa_resp_headers, username)
      otp_body = get_otp_code_body(username)

      # Send OTP Code to SMS flow
      %Req.Response{headers: otp_resp_headers, body: body} =
        Req.post!("#{@url}/login/mfa", body: otp_body, headers: mfa_login_headers)

      # Now you are logged in

      # Get the key to decrypt the account number
      session_key =
        body["enc_session_key"]
        |> Base.decode64!()
        |> Jason.decode!()

      # Get the account id
      [%{"id" => account_id} | _] = body["accounts"]["checking"]

      balance_headers = update_headers(base_headers, otp_resp_headers, username)

      # Send GET request for balance 
      %Req.Response{headers: balance_headers, body: body} =
        Req.get!("#{@url}/accounts/#{account_id}/balances", headers: balance_headers)

      # Get balance here
      balance = body["available"]

      details_headers = update_headers(base_headers, balance_headers, username)

      # Send GET for account details 
      %Req.Response{body: body} =
        Req.get!("#{@url}/accounts/#{account_id}/details", headers: details_headers)

      ciphertext_base = body["number"]

      # Hope the aes settings don't change like the f_token lol
      secret_key = Base.decode64!(session_key["key"])
      ciphertext = Base.decode64!(ciphertext_base)

      raw_decrypt = :crypto.crypto_one_time(:aes_128_ecb, secret_key, ciphertext, false)

      # Pattern matching is great 
      <<_h::binary-16, account_number::binary-12, _t::binary>> = raw_decrypt

      %TellerBank.ChallengeResult{
        account_number: account_number,
        balance_in_cents: balance
      }
    end

    def get_login_body(username, password) do
      %{
        username: username,
        password: password
      }
      |> Jason.encode!()
    end

    def get_sms_body(login_body) do
      [%{"id" => sms_id} | _] = login_body["devices"]

      %{
        device_id: sms_id
      }
      |> Jason.encode!()
    end

    def get_otp_code_body(username) do
      %{
        code: TellerBank.OTPCode.generate(username)
      }
      |> Jason.encode!()
    end

    def update_headers(base_headers, new_headers, username) do
      f_token = get_f_token(new_headers, username)
      request_token = get_request_token(new_headers)

      base_headers
      |> Keyword.put(:request_token, request_token)
      |> Keyword.put(:f_token, f_token)
    end

    def get_f_token(resp_headers, username) do
      f_spec =
        get_f_spec(resp_headers)
        |> Base.decode64!(padding: false)
        |> Jason.decode!()

      separator = f_spec["separator"]
      f_values = Enum.reverse(f_spec["values"])
      req_id = get_req_id(resp_headers)

      f_token_string = get_f_token_string(separator, f_values, username, req_id)

      :crypto.hash(:sha3_256, f_token_string)
      |> Base.encode32()
      |> String.downcase()
      |> String.trim_trailing("=")
    end

    def get_f_token_string(sep, f_values, username, req_id) do
      Enum.reduce(f_values, "", fn v, acc ->
        get_f_value(v, username, req_id) <> sep <> acc
      end)
      |> String.trim_trailing(sep)
    end

    def get_f_value(v, username, req_id) do
      case v do
        "device-id" ->
          @device_id

        "api-key" ->
          @api_key

        "username" ->
          username

        "last-request-id" ->
          req_id
      end
    end

    def get_request_token(resp_headers) do
      Map.new(resp_headers)["request-token"]
    end

    def get_req_id(resp_headers) do
      Map.new(resp_headers)["f-request-id"]
    end

    def get_f_spec(resp_headers) do
      Map.new(resp_headers)["f-token-spec"]
    end
  end
end

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

TellerBank.Client.fetch(username, password)
%TellerBank.ChallengeResult{account_number: "877283704086", balance_in_cents: 48864}