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