PlugGPGVerify behaviour (plug_gpg_verify v0.1.1)

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 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

Example Usage

Put this plug somewhere in your router

scope "/verify" do
  pipe_through(:api)
  forward "/", PlugGPGVerify, adapter: MyProject.GPGVerificationPlug
end

Create a module that implements the PlugGPGVerify behaviour

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

GET /verify

If a user is found (via the 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:

{
  challenge: string,
  user_id: string
}

post-verify

POST /verify

This accepts a json body of:

{
  challenge_response: string,
  user_id: string
}

where challenge_response is the signed challenge

flow-diagram

Flow Diagram

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

Link to this section Summary

Types

The entity passed between the plug and the callbacks

Callbacks

Called when the challenge is successfully created.

Find a user based on the email sent in the GET request

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

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

Link to this section Types

@type challenge() :: binary()
@type user() :: %{id: id(), email: email(), challenge: challenge()}

The entity passed between the plug and the callbacks

Link to this section Callbacks

Link to this callback

challenge_created(user, challenge)

@callback challenge_created(user(), challenge()) :: :ok | {:error, binary()}

Called when the challenge is successfully created.

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

It is also recommended to store an expiration date with the challenge.

Link to this callback

find_user_by_email(email)

@callback find_user_by_email(email()) :: {:ok, user()} | {:error, any()}

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 PlugGPGVerify.user/0 and returned in an :ok tuple.

If this returns an error, a 406 is sent back to the client.

Link to this callback

find_user_by_id(id)

@callback find_user_by_id(id()) :: {:ok, user()} | {:error, any()}

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 PlugGPGVerify.user/0 including the orginial 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

Link to this callback

gpg_verified(t, user)

@callback gpg_verified(Plug.Conn.t(), user()) :: Plug.Conn.t()

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.