mdjohns
Validating Discord Request Headers
Hey all, working on my first Elixir/Phoenix project, which includes a Discord bot.
Per the Discord documentation, applications must validate each request. In order to add an an interactions endpoint URL, the application must validate the request, and respond with the expected response.
In particular, Discord includes two headers:
X-Signature-Ed25519X-Signature-Timestamp
The expectation, as I understand, is to concatenate the timestamp and the raw request body, hash them using my application’s public key, and compare the result with the signature included in Discord’s header.
I’ve created a plug for this purpose, and the code is below. I’m assuming the issue is with the way that I’m creating the hash to compare. When I do that, my hash result is much shorter than the signature included in the header.
Does anything stick out in the code below? Thanks!
defmodule MyApp.DiscordPlug do
@behaviour Plug
import Plug.Conn
require Logger
@impl true
def init(opts), do: opts
@impl true
def call(conn, _) do
with {:ok, signature} <- get_header(conn, "x-signature-ed25519"),
{:ok, timestamp} <- get_header(conn, "x-signature-timestamp"),
{:ok, raw_body} <- get_raw_body(conn),
{:ok, key} <- get_public_key(),
:ok <- valid_request?(signature, timestamp, raw_body, key) do
conn
else
{:error, message} ->
Logger.error("Discord request validation failed with reason: #{message}")
conn
|> send_resp(401, "invalid request signature")
|> halt()
end
end
defp valid_request?(expected_signature, timestamp, body, key) do
payload = "#{timestamp}#{body}"
Logger.info("Constructed payload: #{payload}")
Logger.info("Expected signature: #{expected_signature}")
Logger.info("Signing key: #{key}")
actual_signature =
:crypto.mac(:hmac, :sha256, key, payload)
|> Base.encode16(case: :lower)
|> IO.inspect(label: "Hash result")
case Plug.Crypto.secure_compare(actual_signature, expected_signature) do
true -> :ok
false -> {:error, "Signatures do not match"}
end
end
defp get_header(conn, key) do
case Plug.Conn.get_req_header(conn, key) do
[value] -> {:ok, value}
_ -> {:error, "No header for key #{key}"}
end
end
defp get_raw_body(conn) do
case conn.assigns[:raw_body] do
nil -> {:error, "No raw body present"}
[raw_body] -> {:ok, raw_body} |> IO.inspect(label: "raw body")
end
end
defp get_public_key() do
case Application.get_env(:chronicle, :discord_public_key) do
nil -> {:error, "No public key set"}
value -> {:ok, value}
end
end
end
Marked As Solved
rjk
AFAICS you’re using a SHA256 HMAC signature (verifier) where it should be an Ed25519 signature/verifier.
I also looked into a couple other language implementations and they all seem to use NaCL/Libsodium or Ed25519 library. In (pure) elixir this could be done with ed25519 | Hex
These crypto things are pretty hard to get right so I always try to get a baseline test case with some parameters of which I know should work. Here I’ve put them in a basic unit test below. To be able to test this easily from a unit test perspective I’ve extracted the core “valid_request?” function into a seperate module.
Something like this seems to work;
defmodule DiscordSignatureVerifierTest do
use ExUnit.Case
test "valid_request? with valid signature" do
expected_signature = "f31a129c4e06d93e195ea019392fc568fa7d63c9b43beb436d75f6826d5e5d36270763ee438f13ad5686ed310e8fa3253426af798927bf69cee2ff21be589109"
timestamp = "1625603592"
body = "this should be a json."
key = "e421dceefff3a9d008b7898fcc0974813201800419d72f36d51e010d6a0acb71"
assert DiscordSignatureVerifier.valid_request?(expected_signature, timestamp, body, key) == :ok
end
end
where the valid_request? plug function itself is then
defmodule DiscordSignatureVerifier do
def valid_request?(expected_signature, timestamp, body, key) do
payload = "#{timestamp}#{body}"
case Ed25519.valid_signature?(from_hex(expected_signature), payload, from_hex(key)) do
true -> :ok
false -> {:error, "Signatures do not match"}
end
end
def from_hex(<<>>), do: ""
def from_hex(s) do
size = div(byte_size(s), 2)
{n, ""} = s |> Integer.parse(16)
zero_pad(:binary.encode_unsigned(n), size)
end
def zero_pad(s, size) when byte_size(s) == size, do: s
def zero_pad(s, size) when byte_size(s) < size, do: zero_pad(<<0>> <> s, size)
end
(from_hex/zero_pad comes from ed25519_ex/test_helper.exs at master · mwmiller/ed25519_ex · GitHub )
Hopefully this helps you enough to get on with your project!







