Packages

The Pylon Alexa Request Verifier is an updated fork of the alexa_request_verifier library designed to handle all certificate and request verification for Alexa skills. (See the Alexa Skills Documentation for more information).

Current section

Files

Jump to
pylon_alexa_request_verifier lib alexa_request_verifier.ex
Raw

lib/alexa_request_verifier.ex

defmodule AlexaRequestVerifier do
@moduledoc """
AlexaRequestVerifier verifies an Amazon Alexa Skills request to a
Phoenix server.
There are two options for verifying a request: Manually and automatically.
To automatically verify the request using the verifier as a plug, you will
need to make 3 changes:
1. You will need to add AlexaRequestVerifier as an application in `mix.exs`
```elixir
applications: [..., :pylon_alexa_request_verifier]
```
2. You will need to modify your `endpoint.ex` file by adding the
JSONRawBodyParser as follows:
```elixir
parsers: [AlexaRequestVerifier.JSONRawBodyParser, :urlencoded,
:multipart, :json],
```
The parser is needed to collect the raw body of the request as that is needed
to verify the signature.
3. You will need to add the verifier plug to your pipeline in your
router.ex file:
```elixir
pipeline :alexa_api do
plug :accepts, ["json"]
plug AlexaRequestVerifier
end
```
"""
alias AlexaRequestVerifier.CertCache
alias Plug.Conn
require Logger
@amazon_echo_dns "echo-api.amazon.com"
@sig_header "signature"
@sig_chain_header "signaturecertchainurl"
def init(opts), do: opts
def call(conn, _opts) do
case conn.private[:alexa_verify_test_disable] do
true ->
conn
_ ->
conn = verify_request(conn)
if conn.private[:alexa_verify_error] do
Logger.debug(
"alexa_request_verifier error",
error: conn.private[:alexa_verify_error]
)
conn
|> Conn.send_resp(401, conn.private[:alexa_verify_error])
|> Conn.halt()
else
conn
end
end
end
@doc """
Run all functions required to verify an incoming request as originating
from Amazon, storing an error message in the connection's private params
if validation fails.
"""
@spec verify_request(conn :: Conn.t()) :: Conn.t()
def verify_request(conn) do
conn
|> populate_cert()
|> verify_time()
|> verify_signature()
end
@doc """
Retrieve a valid Amazon certificate, either from the cache or
from source, and store it in the connection's private params.
"""
@spec populate_cert(conn :: Conn.t()) :: Conn.t()
def populate_cert(conn) do
case Conn.get_req_header(conn, @sig_chain_header) do
[] ->
Conn.put_private(
conn,
:alexa_verify_error,
"no request parameter named #{@sig_chain_header}"
)
[cert_url] ->
cert_url = cert_url
cert = CertCache.get_cert(cert_url)
if is_nil(cert) do
cert = fetch_cert(cert_url)
case validate_cert(cert) do
{:ok, _} ->
Logger.debug(
"alexa_request_verifier: caching url",
cert_url: cert_url
)
CertCache.store(cert_url, cert)
Conn.put_private(conn, :signing_cert, cert)
{:error, reason} ->
Logger.debug(
"alexa_request_verifier validation error",
validation_error: reason
)
Conn.put_private(conn, :alexa_verify_error, reason)
end
else
Conn.put_private(conn, :signing_cert, cert)
end
end
end
defp fetch_cert(url) do
if is_correct_alexa_url?(url) do
{:ok, resp} =
:httpc.request(
:get,
{String.to_charlist(url), []},
[],
body_format: :binary
)
{_, _headers, certificate_chain_bin} = resp
cert_chain = :public_key.pem_decode(certificate_chain_bin)
Enum.map(
cert_chain,
fn {_, bin, _} -> bin end
)
else
{:error, "invalid sig chain url"}
end
end
@doc """
Determines whether a request URL represents a valid
Alexa request.
"""
@spec is_correct_alexa_url?(url :: String.t() | URI.t()) :: boolean
def is_correct_alexa_url?(url) when is_binary(url) do
is_correct_alexa_url?(URI.parse(url))
end
def is_correct_alexa_url?(url) when is_nil(url) do
false
end
def is_correct_alexa_url?(%URI{
port: 443,
host: "s3.amazonaws.com",
scheme: "https",
path: "/echo.api/" <> _extra
}) do
true
end
def is_correct_alexa_url?(_everything_else) do
false
end
defp validate_cert({:error, _reason} = err) do
err
end
defp validate_cert(cert) do
cert
|> validate_cert_chain()
|> validate_cert_domain()
end
defp validate_cert_chain(cert) do
reversed = Enum.reverse(cert)
roots = :certifi.cacerts()
if validate_unordered(reversed, roots) do
{:ok, cert}
else
{:error, "no valid root found"}
end
end
defp validate_unordered([], _), do: true
defp validate_unordered(untrusted, trusted) do
# we can't always assume that Amazon will give us their certs in order;
# the iteration is performed with a reversed list of the incoming
# certificates in the hope that it will be, but this ensures validation
# even if the chain is out of order
valid =
Enum.find(
untrusted,
fn cert -> Enum.find(trusted, &validate_root(&1, cert)) end
)
if valid do
untrusted
|> List.delete(valid)
|> validate_unordered([valid | trusted])
else
false
end
end
defp validate_root(root, cert) do
case :public_key.pkix_path_validation(
root,
[cert],
verify_fun: {&verify_fun/3, {}}
) do
{:ok, {_public_key_info, _policy_tree}} -> true
{:error, {:bad_cert, _reason}} -> false
end
end
@doc """
Verify function sent to Erlang's :public_key module for path validation.
"""
@spec verify_fun(cert :: map, event :: {atom, atom | map}, state :: term()) ::
{atom, any}
def verify_fun(_cert, {:extension, _}, state) do
{:unknown, state}
end
def verify_fun(_cert, {:bad_cert, reason}, _state) do
{:fail, reason}
end
def verify_fun(_cert, {:revoked, _}, _state) do
{:fail, :revoked}
end
def verify_fun(_cert, _event, state) do
{:unknown, state}
end
defp validate_cert_domain({:error, _reason} = error) do
error
end
defp validate_cert_domain({:ok, cert}) do
[first | _tail] = cert
if :public_key.pkix_verify_hostname(first, [{:dns_id, @amazon_echo_dns}]) do
{:ok, cert}
else
{:error, "invalid DNS"}
end
end
@doc """
given a Plug.Conn that has a valid Alexa request request/timestamp,
confirms that the timestamp is valid
"""
@spec verify_time(conn :: Conn.t()) :: Conn.t()
def verify_time(conn) do
params = conn.body_params
timestamp = params["request"]["timestamp"]
case is_datetime_valid?(timestamp) do
true ->
conn
false ->
Conn.put_private(conn, :alexa_verify_error, "invalid timestamp")
end
conn
end
@doc """
Determines if a request's timestamp is valid.
"""
@spec is_datetime_valid?(datetime :: String.t() | NaiveDateTime.t() | nil) ::
boolean
def is_datetime_valid?(datetime) when is_nil(datetime), do: false
def is_datetime_valid?(datetime_string) when is_binary(datetime_string) do
case NaiveDateTime.from_iso8601(datetime_string) do
{:ok, datetime} ->
is_datetime_valid?(datetime)
{:error, _reason} ->
false
end
end
def is_datetime_valid?(%NaiveDateTime{} = datetime) do
# minimum number of seconds
min = 150
NaiveDateTime.diff(NaiveDateTime.utc_now(), datetime, :second) <= min
end
@doc """
Assuming :raw_body, :signing_cert, and signature header,
verifies the signature
"""
@spec verify_signature(conn :: Conn.t()) :: Conn.t()
def verify_signature(conn) do
case conn.private[:signing_cert] do
nil ->
Conn.put_private(conn, :alexa_verify_error, "invalid certificate")
_ ->
verify_signature_with_valid_cert(conn)
end
end
defp verify_signature_with_valid_cert(conn) do
message = conn.private[:raw_body]
case Conn.get_req_header(conn, @sig_header) do
[] ->
Conn.put_private(conn, :alexa_verify_error, "no signature")
[signature] ->
{:ok, signature} = Base.decode64(signature)
[first | _tail] = conn.private[:signing_cert]
decoded = :public_key.pkix_decode_cert(first, :otp)
public_key_der = decoded |> elem(1) |> elem(7) |> elem(2)
if !is_nil(public_key_der) &&
:public_key.verify(message, :sha, signature, public_key_der) do
conn
else
Conn.put_private(conn, :alexa_verify_error, "signature did not match")
end
end
end
end