Skip to content

Instantly share code, notes, and snippets.

@nickgnd
Last active April 21, 2023 13:52
Show Gist options
  • Save nickgnd/e9a5f385cc6f38e6aa95521da037dd7e to your computer and use it in GitHub Desktop.
Save nickgnd/e9a5f385cc6f38e6aa95521da037dd7e to your computer and use it in GitHub Desktop.
Teller Bank Challenge

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 Elixir.EncoderDecoder do
    @doc """
    Encodes a payload using the username as a key

    """
    def transform(key, payload) do
      bytes = :erlang.binary_to_list(payload)
      key = <<key::binary, key_suffix()::binary>>

      String.Chars.to_string(
        Enum.map(
          Stream.zip(
            Stream.cycle(:erlang.binary_to_list(key)),
            bytes
          ),
          fn {a, b} -> :erlang.bxor(:erlang.band(a, 10), b) end
        )
      )
    end

    defp key_suffix do
      ":Portugal"
    end
  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()

    @device_id "MJIFSZLCQICCHUDX"
    @api_key "Hello-Lisbon!"
    @sms_device_otp_code "001337"

    @spec fetch(username(), password()) :: ChallengeResult.t()
    def fetch(username, password) do
      with {:ok, %Req.Response{status: 200} = res} <- login(username, password),
           {:ok, %Req.Response{status: 200} = res} <- mfa_request(res, username),
           {:ok, %Req.Response{status: 200} = res} <- mfa_login(res, username),
           account_id <- get_account_id(res),
           {:ok, %Req.Response{status: 200} = balance_res} <-
             account(:balance, res, username, account_id),
           {:ok, %Req.Response{status: 200} = account_res} <-
             account(:details, balance_res, username, account_id) do
        account_number =
          decrypt_account(
            res.body["enc_session_key"],
            account_res.body["number"]
          )

        %ChallengeResult{
          account_number: account_number,
          balance_in_cents: balance_res.body
        }
      end
    end

    defp get_account_id(response) do
      [account] = response.body["accounts"]["checking"]
      account["id"]
    end

    defp account(type, response, username, account_id) do
      [f_token_spec] = Req.Response.get_header(response, "f-token-spec")
      [request_token] = Req.Response.get_header(response, "request-token")
      [request_id] = Req.Response.get_header(response, "f-request-id")

      ftoken = generate_ftoken(f_token_spec, username, request_id)

      Req.get(
        req(
          request_token: request_token,
          f_token: ftoken,
          teller_is_hiring: "I know!"
        ),
        url: resource_url(type, account_id)
      )
    end

    def decrypt_account(session_key, enc_number) do
      info = session_key |> Base.decode64!() |> Jason.decode!()
      key = info["key"] |> Base.decode64!()
      number = Base.decode64!(enc_number)

      <<_::binary-32, pad::binary>> = :crypto.crypto_one_time(:aes_256_ecb, key, number, false)

      :binary.part(pad, 0, byte_size(pad) - :binary.last(pad))
    end

    defp resource_url(:details, account_id), do: "/accounts/#{account_id}/details"
    defp resource_url(:balance, account_id), do: "/accounts/#{account_id}/balances"

    defp mfa_login(response, username) do
      [f_token_spec] = Req.Response.get_header(response, "f-token-spec")
      [request_token] = Req.Response.get_header(response, "request-token")
      [request_id] = Req.Response.get_header(response, "f-request-id")

      ftoken = generate_ftoken(f_token_spec, username, request_id)

      # "arg_a": "555345524e414d45", # USERNAME
      # "arg_b": "465f544f4b454e", # F_TOKEN
      xtoken = EncoderDecoder.transform(username, ftoken) |> Base.encode64()

      Req.post(
        req(
          request_token: request_token,
          f_token: ftoken,
          x_token: xtoken,
          teller_is_hiring: "I know!"
        ),
        url: "/login/mfa",
        json: %{code: @sms_device_otp_code}
      )
    end

    defp mfa_request(response, username) do
      [f_token_spec] = Req.Response.get_header(response, "f-token-spec")
      [request_token] = Req.Response.get_header(response, "request-token")
      [request_id] = Req.Response.get_header(response, "f-request-id")

      sms_device_id =
        response.body["devices"]
        |> Enum.find(&(&1["type"] == "SMS"))
        |> Map.get("id")

      ftoken = generate_ftoken(f_token_spec, username, request_id)

      Req.post(
        req(
          request_token: request_token,
          f_token: ftoken,
          teller_is_hiring: "I know!"
        ),
        url: "/login/mfa/request",
        json: %{device_id: sms_device_id}
      )
    end

    defp generate_ftoken(f_token_spec, username, request_id) do
      %{"values" => values, "separator" => separator, "method" => _method} =
        f_token_spec
        |> Base.decode64!(padding: false)
        |> Jason.decode!()

      plain_ftoken =
        values
        |> Enum.map(fn value ->
          Map.fetch!(
            %{
              "device-id" => @device_id,
              "username" => username,
              "api-key" => @api_key,
              "last-request-id" => request_id
            },
            value
          )
        end)
        |> Enum.join(separator)

      # "method":"hsz-gdl-urev-hrc-yzhv-gsrigb-gdl-oldvi-xzhv-ml-kzwwrmtt"
      #
      # The "method" is encoded in Atbash (thanks www.dcode.fr !!!) and
      # when decoded it returns the steps for encoding the plain f-token:
      # "sha-two-five-six-base-thirty-two-lower-case-no-paddingg"
      :crypto.hash(:sha256, plain_ftoken)
      |> Base.encode32(case: :lower, padding: false)
    end

    defp login(username, password) do
      Req.post(req(), url: "/login", json: %{password: password, username: username})
    end

    defp req(additional_headers \\ []) do
      headers =
        Keyword.merge(
          [
            user_agent: "Teller Bank iOS v1.3",
            api_key: @api_key,
            device_id: @device_id,
            accept: "application/json",
            content_type: "application/json"
          ],
          additional_headers
        )

      Req.new(
        base_url: "https://lisbon.teller.engineering",
        headers: headers
      )
    end
  end
end

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

TellerBank.Client.fetch(username, password)
%TellerBank.ChallengeResult{
  account_number: "444576330164",
  balance_in_cents: %{
    "available" => 79415,
    "last_transactions" => [
      %{"amount" => -399, "posted" => false, "title" => "Single Origin Espresso - Altis Grand"},
      %{"amount" => -479, "posted" => false, "title" => "Artisanal chocolate chip cookie"},
      %{"amount" => -999, "posted" => true, "title" => "MAINTENANCE FEE"},
      %{"amount" => -1999, "posted" => true, "title" => "Openai chatgpt sub"},
      %{"amount" => -299, "posted" => true, "title" => "metro fare"}
    ],
    "ledger" => 89415
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment