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)