lib/plug_gpg_verify.ex

defmodule PlugGPGVerify do
  @moduledoc """
  plug_gpg_verify does the work of verifing a public key by generating a random 
  challenge, sending that challenge to the user and expecting the response, having
  the client sign the challenge and send it back, then verifying the signature.

  This makes a couple of assumptions:
  1. GPG is setup and working correctly on your system.
      -  this uses [gpgmex](https://hexdocs.pm/gpgmex/GPG.html) which requires the rust toolchain installed and working
  2. The public key we are validating has already been imported

  What this is **NOT**
  1. This is **NOT** a way to authenticate. Authentication is left as an excersise to the user of the library
  2. This does **NOT** in any way import PGP keys, or verify that the email associated with the public_key
  is valid.

  ## Example Usage
  Put this plug somewhere in your router
  ```elixir
  scope "/verify" do
    pipe_through(:api)
    forward "/", PlugGPGVerify, adapter: MyProject.GPGVerificationPlug
  end
  ```

  Create a module that implements the PlugGPGVerify behaviour
  ```elixir
  defmodule MyProject.GPGVerificationPlug do
    use PlugGPGVerify

    @impl true
    def find_user_by_email(email) do
      case Repo.get_by(User, :email, email) do
        nil -> {:error, :not_found}
        user -> {:ok, %{id: user.id, email: user.email}}
      end
    end

    @impl true
    def challenge_created(user, challenge) do
      changeset = User.changeset(
        %User{id: user.id, email: user.email}, 
        %{
          challenge: plain_text_challenge,
          challenge_expiration: DateTime.add(DateTime.utc_now(), 1, :hour)
        }
      )
      Repo.update(changeset)
    end

    @impl true
    def find_user_by_id(id) do
      case Repo.get(User, id) do
        nil -> {:error, :not_found}
        user ->
          # verify expiration
          {:ok, %{id: user.id, email: user.email, challenge: challenge}}
      end
    end

    @impl true
    def gpg_verified(conn, user) do 
      # do whatever you want with the connection
      token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user auth", user.id)
      conn
      |> put_status(200)
      |> Controller.json(%{token: token})
    end
  end
  ```

  Your application accepts two new requests at `/verify` (or whatever route you defined): 
  * GET /verify?email="user@email.com"
  * POST /verify

  ### GET /verify
  If a user is found (via the `c:find_user_by_email/1` callback),
  AND they have a public_key configured on the system, a new challenge is generated and encrypted.

  A 201 is sent back with a JSON response of:
  ```json
  {
    challenge: string,
    user_id: string
  }
  ```

  ### POST /verify
  This accepts a json body of:
  ```json
  {
    challenge_response: string,
    user_id: string
  }
  ```
  where challenge_response is the signed challenge

  ## Flow Diagram

  ```mermaid
  sequenceDiagram
    Client->>Server: GET /verify?email=example@email.com
    Server->>Client: {user_id: 1234, challenge: "challenge string"}
    Client->>Server: POST /verify {user_id: 1234, challenge_response: "-----BEGIN PGP MESSAGE----- ..."}
    Server->>Client: 200
  ```
  """

  @behaviour Plug
  alias Plug.Conn
  alias PlugGPGVerify.Verification

  @impl Plug
  def init(adapter: adapter), do: adapter
  def init(_), do: raise("adapter is a required option for PlugGPGVerify")

  @impl Plug
  def call(%{method: method} = conn, module) do
    case validate_method(method) do
      :invalid -> Verification.invalid_method(conn)
      :get -> Verification.generate_challenge(conn, module)
      :post -> Verification.validate_challenge(conn, module)
    end
  end

  @spec validate_method(String.t()) :: :get | :post | :invalid
  defp validate_method(method) do
    case String.downcase(method) do
      "get" -> :get
      "post" -> :post
      _other -> :invalid
    end
  end

  @typep email :: binary()
  @typep id :: any()
  @type challenge() :: binary()

  @typedoc """
  The entity passed between the plug and the callbacks
  """
  @type user :: %{id: id(), email: email(), challenge: challenge()}

  @doc """
  Find a user based on the email sent in the GET request

  Typically this is would call into the database to find a user and that user should have a public_key.
  Then it is mapped to a `t:PlugGPGVerify.user/0` and returned in an `:ok` tuple.

  If this returns an error, a 406 is sent back to the client.
  """
  @callback find_user_by_email(email()) :: {:ok, user()} | {:error, any()}

  @doc """
  Find a user based on the id sent back via the POST request.

  When the client sends the POST request, the are required to send the id of the user back
  instead of relying on the email. This callback is called to get the `t:PlugGPGVerify.user/0`
  including the orginial `t:PlugGPGVerify.challenge/0` created.

  Most implementations will also verify that the challenge hasn't expired based on their own
  rules.

  If an `{:ok, user}` is returned, verification continues

  If an `{:error, reason}` is returned, a 401 is sent back to the client
  """
  @callback find_user_by_id(id()) :: {:ok, user()} | {:error, any()}

  @doc """
  Called when the challenge is successfully created.

  It is expected that the implementation stores the challenge somewhere to be recalled when
  `c:find_user_by_id/1` is called during the POST request. 

  It is also recommended to store an expiration date with the challenge.
  """
  @callback challenge_created(user(), challenge()) :: :ok | {:error, binary()}

  @doc """
  Called when the GPG public key has been verified because the challenge matches.

  This is the final step in the happy path of verification.

  This will return the `Plug.Conn` and it's up to the implementation to handle next steps.

  Most implementations will likely generate a token, return it to the user and store it in the session/db.
  """
  @callback gpg_verified(Conn.t(), user()) :: Conn.t()

  defmacro __using__(_) do
    quote location: :keep do
      require unquote(__MODULE__)
      @behaviour PlugGPGVerify
    end
  end
end