Current section

Files

Jump to
charon lib charon token_factory symmetric_jwt.ex
Raw

lib/charon/token_factory/symmetric_jwt.ex

defmodule Charon.TokenFactory.SymmetricJwt do
@moduledoc """
The default and most simple form of self-signed tokens,
JWTs with symmetric-key signatures.
These are suited for everything but OpenID Connect implementations,
because these require third parties to verify the token signature,
which requires asymetric keys.
## Config
Additional config is required for this module (see `Charon.TokenFactory.SymmetricJwt.Config`):
Charon.Config.from_enum(
...,
optional_modules: %{
Charon.TokenFactory.SymmetricJwt => %{
get_secret: fn -> :crypto.strong_rand_bytes(32) end,
algorithm: :poly1305,
json_module: Jason
}
}
)
The following options are supported:
- `:get_secret` (required). A getter/0 for secret for the JWT's signature algorithm. Must be exactly 256 bits in case of Poly1305 alg.
- `:algorithm` (optional). The token signature algorithm, may be `:sha256` (default), `:sha384`, `:sha512` or `:poly1305`.
- `:json_module` (optional, default Jason). The JSON encoding lib.
## Deriving the secret from Phoenix's `:secret_key_base`
It is possible to use Phoenix's `:secret_key_base` as the secret for the token factory.
However, unlike `Phoenix.Token`, this module does not process the secret using PBKDF2 by default.
This is not a problem when a dedicated secret is used, but in the case of `:secret_key_base`, the reuse of the secret will weaken security without such key derivation.
It is very easy to add such preprocessing yourself, however, thanks to `Plug.Crypto.KeyGenerator`:
defmodule MyApp.Charon do
@token_salt "charon_token"
def get_token_secret() do
base_secret = Application.get_env(:my_app, MyAppWeb.Endpoint)[:secret_key_base]
Plug.Crypto.KeyGenerator.generate(base_secret, @token_salt)
end
end
And then pass `MyApp.Charon.get_token_secret/0` to this module's config, naturally.
## Examples / doctests
@base_key :crypto.strong_rand_bytes(32)
@payload %{"claim" => "value"}
@mod_conf SymmetricJwt.Config.from_enum(get_secret: &__MODULE__.get_secret/0)
@config %{optional_modules: %{SymmetricJwt => @mod_conf}}
def get_secret(), do: @base_key
# verify ignores the config's algorithm, grabbing it from the JWT header instead
# this allows changing algorithms without invalidating existing JWTs
iex> {:ok, token} = sign(@payload, @config)
iex> config = %{optional_modules: %{SymmetricJwt => %{@mod_conf | algorithm: :sha512}}}
iex> verify(token, config)
{:ok, @payload}
# gracefully handles malformed tokens / unsupported algo's / invalid signature
iex> verify("a", @config)
{:error, "malformed token"}
iex> verify("a.b.c", @config)
{:error, "encoding invalid"}
iex> header = %{"alg" => "boom"} |> Jason.encode!() |> Base.url_encode64(padding: false)
iex> verify(header <> ".YQ.YQ", @config)
{:error, "unsupported signature algorithm"}
iex> header = %{"alg" => "HS256"} |> Jason.encode!() |> Base.url_encode64(padding: false)
iex> verify(header <> ".YQ.YQ", @config)
{:error, "signature invalid"}
# # poly1305 is experimentally supported, and requires a 256-bits key
@poly1305_config %{optional_modules: %{SymmetricJwt => %{@mod_conf | algorithm: :poly1305}}}
iex> {:ok, token} = sign(@payload, @poly1305_config)
iex> verify(token, @poly1305_config)
{:ok, @payload}
iex> header = token |> String.split(".") |> List.first() |> Base.url_decode64!() |> Jason.decode!()
iex> %{"alg" => "Poly1305", "nonce" => <<_::binary>>, "typ" => "JWT"} = header
iex> wrong_secret_conf = %{optional_modules: %{SymmetricJwt => %{@mod_conf | get_secret: fn -> :crypto.strong_rand_bytes(32) end}}}
iex> {:error, "signature invalid"} = verify(token, wrong_secret_conf)
"""
@behaviour Charon.TokenFactory.Behaviour
@encoding_opts padding: false
@alg_to_header_map %{
sha256: "HS256",
sha384: "HS384",
sha512: "HS512",
poly1305: "Poly1305"
}
@header_to_alg_map Map.new(@alg_to_header_map, fn {k, v} -> {v, k} end)
@impl true
def sign(payload, config) do
%{algorithm: alg, get_secret: get_secret, json_module: jmod} = get_module_config(config)
secret = get_secret.()
with {:ok, json_payload} <- jmod.encode(payload) do
payload = url_encode(json_payload)
{header_base_pl, secret} = gen_header_pl_and_secret(alg, secret)
header = gen_header(header_base_pl, alg, jmod)
token = generate_token(header, payload, alg, secret)
{:ok, token}
else
_ -> {:error, "could not encode payload"}
end
end
@impl true
def verify(token, config) do
%{get_secret: get_secret, json_module: jmod} = get_module_config(config)
secret = get_secret.()
with [header, payload, signature] <- String.split(token, ".", parts: 3),
{:ok, alg, nonce} <- process_header(header, jmod),
secret = generate_msg_secret(secret, nonce),
mac_base = [header, ?., payload],
mac = calc_mac(mac_base, secret, alg),
{:ok, signature} <- url_decode(signature),
true <- Plug.Crypto.secure_compare(mac, signature),
{:ok, payload} <- to_map(payload, jmod) do
{:ok, payload}
else
false -> {:error, "signature invalid"}
error = {:error, <<_::binary>>} -> error
_ -> {:error, "malformed token"}
end
end
@doc false
def init_config(enum), do: __MODULE__.Config.from_enum(enum)
###########
# Private #
###########
defp get_module_config(%{optional_modules: %{__MODULE__ => config}}), do: config
defp url_encode(bin), do: Base.url_encode64(bin, @encoding_opts)
defp url_decode(bin), do: Base.url_decode64(bin, @encoding_opts)
defp gen_header_pl_and_secret(:poly1305, secret) do
nonce = :crypto.strong_rand_bytes(12)
header = %{nonce: url_encode(nonce)}
otk = gen_poly1305_key(secret, nonce)
{header, otk}
end
defp gen_header_pl_and_secret(_alg, secret), do: {%{}, secret}
defp generate_token(header, payload, alg, secret) do
mac_base = [header, ?., payload]
mac = calc_mac(mac_base, secret, alg) |> url_encode()
[mac_base, ?., mac] |> IO.iodata_to_binary()
end
defp gen_header(base, alg, jmod) do
base
|> Map.merge(%{alg: Map.get(@alg_to_header_map, alg), typ: "JWT"})
|> jmod.encode!()
|> url_encode()
end
defp calc_mac(data, secret, :poly1305), do: :crypto.mac(:poly1305, secret, data)
defp calc_mac(data, secret, alg), do: :crypto.mac(:hmac, alg, secret, data)
defp to_map(encoded, json_mod) do
with {:ok, json} <- url_decode(encoded),
{:ok, payload} <- json_mod.decode(json) do
{:ok, payload}
else
:error -> {:error, "encoding invalid"}
error -> error
end
end
defp process_header(header, json_mod) do
with {:ok, payload} <- to_map(header, json_mod),
%{"alg" => alg} <- payload,
alg when not is_nil(alg) <- Map.get(@header_to_alg_map, alg) do
{:ok, alg, get_nonce_from_header_pl(payload)}
else
%{} -> {:error, "malformed header"}
nil -> {:error, "unsupported signature algorithm"}
error -> error
end
end
defp get_nonce_from_header_pl(%{"nonce" => nonce}) when is_binary(nonce) do
case url_decode(nonce) do
{:ok, <<nonce::binary-size(12)>>} -> nonce
_ -> nil
end
end
defp get_nonce_from_header_pl(_), do: nil
defp generate_msg_secret(secret, nil), do: secret
defp generate_msg_secret(secret, nonce), do: gen_poly1305_key(secret, nonce)
defp gen_poly1305_key(key, nonce) do
:crypto.crypto_one_time(:chacha20, key, <<0::32, nonce::binary>>, <<0::256>>, true)
end
end