Skip to main content

lib/break_glass/otp_store.ex

defmodule BreakGlass.OtpStore do
  @moduledoc """
  GenServer that owns the `:break_glass_otp` ETS table.

  Maintains at most one pending OTP record at any time. Generating a new code
  atomically invalidates any previously issued code.

  ## ETS Table

  Table name: `:break_glass_otp`
  Record format: `{:pending_otp, code :: String.t(), issued_at :: integer()}`
  Access: `:protected`

  `verify/1` reads ETS directly, then **always** calls
  `GenServer.call(__MODULE__, :clear)` before returning, regardless of the
  outcome. This prevents replay attacks.

  OTP TTL is 600 seconds. The TTL check precedes code comparison so an expired
  code always returns `{:error, :expired}`.

  ## Functions

  - `generate/0` — produces a 6-digit zero-padded OTP, stores it with a fresh
    issue timestamp, and returns the code string
  - `verify/1` — validates the code against the stored OTP; always clears the
    store before returning
  - `clear/0` — unconditionally removes the pending OTP record
  """

  use GenServer

  require Logger

  @table :break_glass_otp
  @ttl_seconds 600

  # ---------------------------------------------------------------------------
  # Public API
  # ---------------------------------------------------------------------------

  @doc """
  Starts the OtpStore GenServer and registers it under `#{__MODULE__}`.
  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Generates a 6-digit zero-padded OTP code, stores it with a fresh issue
  timestamp, and returns the code string.

  Any previously stored OTP is atomically replaced.
  """
  @spec generate() :: String.t()
  def generate do
    GenServer.call(__MODULE__, :generate)
  end

  @doc """
  Verifies `code` against the stored OTP.

  Reads ETS directly for the lookup, then **always** calls
  `GenServer.call(__MODULE__, :clear)` before returning (regardless of the
  outcome) to prevent replay attacks.

  Returns:
  - `:ok` — code matches and has not expired
  - `{:error, :expired}` — OTP TTL (600 s) has elapsed
  - `{:error, :invalid}` — code does not match
  - `{:error, :no_pending_otp}` — no OTP has been generated yet
  """
  @spec verify(code :: String.t()) ::
          :ok | {:error, :invalid} | {:error, :expired} | {:error, :no_pending_otp}
  def verify(code) do
    result =
      case :ets.lookup(@table, :pending_otp) do
        [{:pending_otp, stored_code, issued_at}] ->
          now = System.system_time(:second)

          cond do
            now - issued_at >= @ttl_seconds -> {:error, :expired}
            code == stored_code -> :ok
            true -> {:error, :invalid}
          end

        [] ->
          {:error, :no_pending_otp}
      end

    # Always clear before returning — prevents replay attacks
    GenServer.call(__MODULE__, :clear)
    result
  end

  @doc """
  Unconditionally removes the pending OTP record from the ETS table.

  Returns `:ok`.
  """
  @spec clear() :: :ok
  def clear do
    GenServer.call(__MODULE__, :clear)
  end

  # ---------------------------------------------------------------------------
  # GenServer callbacks
  # ---------------------------------------------------------------------------

  @impl true
  def init(_opts) do
    :ets.new(@table, [:set, :protected, :named_table])
    {:ok, %{}}
  end

  @impl true
  def handle_call(:generate, _from, state) do
    n = :rand.uniform(1_000_000) - 1
    code = :io_lib.format("~6..0B", [n]) |> IO.iodata_to_binary()
    issued_at = System.system_time(:second)
    :ets.insert(@table, {:pending_otp, code, issued_at})
    {:reply, code, state}
  end

  @impl true
  def handle_call(:clear, _from, state) do
    :ets.delete(@table, :pending_otp)
    {:reply, :ok, state}
  end
end