defmodule WaxAPIREST.Plug do
@moduledoc """
A plug that exposes the FIDO2 REST API
[7. Transport Binding Profil](https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html#transport-binding-profile).
## Usage
In a Phoenix router, forward a route to the `WaxAPIREST.Plug`:
defmodule MyApp.Router do
use Phoenix.Router
forward "/webauthn", WaxAPIREST.Plug, callback: MyApp.WebAuthnCallbackModule
end
If you're using `Plug.Router`:
defmodule MyApp.Router do
use Plug.Router
forward "/webauthn", to: WaxAPIREST.Plug, callback: MyApp.WebAuthnCallbackModule
end
## Callback module
An implementation of the `WaxAPIREST.Callback` module must be provided as an option or
in the configuration file.
## Options
In addition to Wax's options (`t:Wax.opt/0`), the `t:opts/0` can be used specifically
with this plug.
For instance, using Phoenix:
defmodule MyApp.Router do
use Phoenix.Router
forward "/webauthn", WaxAPIREST.Plug, [
callback_module: MyApp.WebAuthnCallbackModule,
rp_name: "My site",
pub_key_cred_params: [-36, -35, -7, -259, -258, -257] # allows RSA algs
]
end
"""
use Plug.Router
use Plug.ErrorHandler
alias WaxAPIREST.Types.{
AttestationConveyancePreference,
AuthenticatorSelectionCriteria,
PubKeyCredParams,
PublicKeyCredentialRpEntity,
ServerPublicKeyCredential,
ServerPublicKeyCredentialCreationOptionsRequest,
ServerPublicKeyCredentialCreationOptionsResponse,
ServerPublicKeyCredentialDescriptor,
ServerPublicKeyCredentialGetOptionsRequest,
ServerPublicKeyCredentialGetOptionsResponse,
ServerPublicKeyCredentialUserEntity
}
@type opts :: [Wax.opt() | opt()]
@typedoc """
In addition to the Wax options, this library defines the following options:
- `:callback_module` [**mandatory**]: the callback module, no default
- `:rp_name`: a [human-palatable identifier for the Relying Party](https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialentity).
If not present, defaults to the RP id (`Wax` option `:rp_id`)
- `:pub_key_cred_params`: the list of allowed credential algorithms. Defaults to
`[-36, -35, -7]` which are ES512, ES384 and ES256 in this order of precedence. These
values have been chosen using the following security analysis:
[Security Concerns Surrounding WebAuthn: Don't Implement ECDAA (Yet)](https://paragonie.com/blog/2018/08/security-concerns-surrounding-webauthn-don-t-implement-ecdaa-yet)
- `:attestation_conveyance_preference`: the attestation conveyance preference. Defaults
to the value of the request or, if absent, to `"none"`
The options can be configured (in order of precedence):
- through options passed as a parameter to the plug router
- in the configuration file (under the `WaxAPIREST` key)
"""
@type opt ::
{:callback_module, module()}
| {:rp_name, String.t()}
| {:pub_key_cred_params, [Wax.CoseKey.cose_alg()]}
| {:attestation_conveyance_preference, AttestationConveyancePreference.t()}
plug :match
plug :dispatch, builder_opts()
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
post "/attestation/options" do
callback_module = callback_module(opts)
creation_request = ServerPublicKeyCredentialCreationOptionsRequest.new(conn.body_params)
challenge =
opts
|> Keyword.put(:attestation, creation_request.attestation)
|> Wax.new_registration_challenge()
user_info = callback_module.user_info(conn)
exclude_credentials =
callback_module.user_keys(conn)
|> Enum.map(
fn
{key_id, %{transports: transports}} ->
ServerPublicKeyCredentialDescriptor.new(key_id, transports)
{key_id, _} ->
ServerPublicKeyCredentialDescriptor.new(key_id)
end
)
response = ServerPublicKeyCredentialCreationOptionsResponse.new(
creation_request,
challenge,
user_info,
Keyword.put(opts, :exclude_credentials, exclude_credentials)
)
conn
|> callback_module.put_challenge(challenge)
|> send_json(200, response)
end
post "/attestation/result" do
callback_module = callback_module(opts)
challenge = callback_module.get_challenge(conn)
registration_request = ServerPublicKeyCredential.new(conn.body_params)
Wax.register(
Base.url_decode64!(registration_request.response.attestationObject, padding: false),
Base.url_decode64!(registration_request.response.clientDataJSON, padding: false),
challenge
)
|> case do
{:ok, {authenticator_data, attestation_result}} ->
callback_module.register_key(
conn,
registration_request.rawId,
authenticator_data,
attestation_result
)
|> send_json(200, %{
"status" => "ok",
"errorMessage" => ""
})
{:error, e} ->
send_json(conn, 400, %{"status" => "failed", "errorMessage" => Exception.message(e)})
end
end
post "/assertion/options" do
callback_module = callback_module(opts)
creation_request = ServerPublicKeyCredentialGetOptionsRequest.new(conn.body_params)
allow_credentials =
conn
|> callback_module.user_keys()
|> Enum.map(fn {cred_id, %{cose_key: cose_key}} -> {cred_id, cose_key} end)
challenge_opts =
opts
|> Keyword.put(:user_verification, creation_request.userVerification)
|> Keyword.put(:allow_credentials, allow_credentials)
challenge = Wax.new_authentication_challenge(challenge_opts)
response = ServerPublicKeyCredentialGetOptionsResponse.new(
creation_request,
challenge,
Enum.map(allow_credentials, fn {key_id, _} -> key_id end),
opts
)
conn
|> callback_module.put_challenge(challenge)
|> send_json(200, response)
end
post "/assertion/result" do
callback_module = callback_module(opts)
challenge = callback_module.get_challenge(conn)
authn_request = ServerPublicKeyCredential.new(conn.body_params)
Wax.authenticate(
authn_request.rawId,
Base.url_decode64!(authn_request.response.authenticatorData, padding: false),
Base.url_decode64!(authn_request.response.signature, padding: false),
Base.url_decode64!(authn_request.response.clientDataJSON, padding: false),
challenge
)
|> case do
{:ok, authenticator_data} ->
user_keys = callback_module.user_keys(conn)
if sign_count_valid?(authn_request.rawId, authenticator_data, user_keys) do
callback_module.on_authentication_success(
conn,
authn_request.rawId,
authenticator_data
)
|> send_json(200, %{
"status" => "ok",
"errorMessage" => ""
})
else
send_json(conn, 400, %{
"status" => "failed",
"errorMessage" => "invalid sign count"
})
end
{:error, e} ->
send_json(conn, 400, %{"status" => "failed", "errorMessage" => Exception.message(e)})
end
end
@spec sign_count_valid?(
Wax.CredentialId.t(),
Wax.AuthenticatorData.t(),
WaxAPIREST.Callback.user_keys()
) :: boolean()
defp sign_count_valid?(raw_id, authenticator_data, user_keys) do
saved_sign_count =
Enum.find_value(
user_keys,
fn
{^raw_id, %{sign_count: sign_count}} ->
sign_count
_ -> false
end
)
new_sign_count = authenticator_data.sign_count
if saved_sign_count != nil and saved_sign_count > 0 or new_sign_count > 0 do
new_sign_count > saved_sign_count
else
true
end
end
defp send_json(conn, status, response) do
body = Jason.encode!(response)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> send_resp(status, body)
end
def handle_errors(conn, %{kind: _kind, reason: e, stack: _stack}) do
error(conn, e)
end
@spec error(Plug.Conn.t(), Exception.t() | any()) :: Plug.Conn.t()
defp error(conn, error) do
message =
case error do
%_{} ->
Exception.message(error)
_ ->
to_string(error)
end
resp =
%{status: "failed", errorMessage: message}
|> Jason.encode!()
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, resp)
end
@spec callback_module(opts()) :: module()
def callback_module(opts) do
opts[:callback_module]
|| Application.get_env(WaxAPIREST, :callback_module)
|| raise "callback module not configured"
end
defimpl Jason.Encoder, for: [
AuthenticatorSelectionCriteria,
PubKeyCredParams,
PublicKeyCredentialRpEntity,
ServerPublicKeyCredentialCreationOptionsResponse,
ServerPublicKeyCredentialDescriptor,
ServerPublicKeyCredentialGetOptionsResponse,
ServerPublicKeyCredentialUserEntity
] do
def encode(struct, opts) do
struct
|> Map.from_struct()
|> Enum.filter(fn {_k, v} -> v != nil end)
|> Enum.into(%{})
|> Jason.Encode.map(opts)
end
end
end