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