diff --git a/README.md b/README.md index 7ef8171..4b4ace3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ elixir-radius ============= +[![CI](https://github.com/bearice/elixir-radius/actions/workflows/elixir.yml/badge.svg)](https://github.com/bearice/elixir-radius/actions/workflows/elixir.yml) + +[![Hex.pm version](https://img.shields.io/hexpm/v/elixir_radius.svg?style=flat)](https://hex.pm/packages/elixir_radius) + RADIUS protocol encoding and decoding example @@ -16,8 +20,8 @@ loop = fn(loop)-> IO.puts "From #{inspect host} : \n#{inspect p, pretty: true}" - resp = %Radius.Packet{code: "Access-Reject", id: p.id, auth: p.auth, secret: p.secret} - Radius.send sk,host,resp + resp = %Radius.Packet{code: "Access-Reject", id: p.id, secret: p.secret} + Radius.send_reply(sk, host, resp, p.auth) loop.(loop) end diff --git a/example.exs b/example.exs index bc16674..29a1ca8 100644 --- a/example.exs +++ b/example.exs @@ -38,21 +38,21 @@ attrs = [ {255, "123456"} ] -# for request packets, leave auth=nil will generate with random bytes -p = %Radius.Packet{code: "Access-Request", id: 12, auth: nil, secret: secret, attrs: attrs} +# for request packets, authenticator will generate with random bytes +p = %Radius.Packet{code: "Access-Request", id: 12, secret: secret, attrs: attrs} # will return an iolist -data = Radius.Packet.encode(p) -Logger.debug("data=#{inspect(data)}") +%{raw: data} = Radius.Packet.encode_request(p) +Logger.debug("data=#{inspect(packet.raw)}") p = Radius.Packet.decode(:erlang.iolist_to_binary(data), secret) Logger.debug(inspect(p, pretty: true)) -# for response packets, set auth=request.auth to generate new HMAC-hash with it. -p = %Radius.Packet{code: "Access-Accept", id: 12, auth: p.auth, secret: secret, attrs: p.attrs} -data = Radius.Packet.encode(p) +# for response packets, provide request.auth to generate new HMAC-hash with it. +p2 = %Radius.Packet{code: "Access-Accept", id: 12, secret: secret, attrs: p.attrs} +%{raw: data} = Radius.Packet.encode_reply(p2, p.auth) Logger.debug("data=#{inspect(data)}") # password decoding SHOULD FAIL here, guess why? -p = Radius.Packet.decode(:erlang.iolist_to_binary(data), p.secret) +p = Radius.Packet.decode(:erlang.iolist_to_binary(data), p2.secret) Logger.debug(inspect(p, pretty: true)) # wrapper of gen_udp @@ -65,8 +65,8 @@ loop = fn loop -> IO.puts("From #{inspect(host)} : \n#{inspect(p, pretty: true)}") - resp = %Radius.Packet{code: "Access-Reject", id: p.id, auth: p.auth, secret: p.secret} - Radius.send(sk, host, resp) + resp = %Radius.Packet{code: "Access-Reject", id: p.id, secret: p.secret} + Radius.send_reply(sk, host, resp, p.auth) loop.(loop) end diff --git a/lib/radius.ex b/lib/radius.ex index d209ba2..c80395d 100644 --- a/lib/radius.ex +++ b/lib/radius.ex @@ -31,8 +31,35 @@ defmodule Radius do sk :: socket packet:: %Radius.Packet{} """ + @deprecated "Use send_reply/4 or send_request/3" def send(sk, {host, port}, packet) do - data = Packet.encode(packet) + send_reply(sk, {host, port}, packet, packet.auth) + end + + @doc """ + encode and send reply packet + """ + @spec send_reply( + socket :: port(), + {host :: :inet.ip_address(), port :: :inet.port_number()}, + packet :: Packet.t(), + request_authenticator :: binary() + ) :: :ok | {:error, any()} + def send_reply(sk, {host, port}, packet, request_authenticator) do + %{raw: data} = Packet.encode_reply(packet, request_authenticator) + :gen_udp.send(sk, host, port, data) + end + + @doc """ + encode and send request packet + """ + @spec send_request( + socket :: port(), + {host :: :inet.ip_address(), port :: :inet.port_number()}, + packet :: Packet.t() + ) :: :ok | {:error, any()} + def send_request(sk, {host, port}, packet) do + %{raw: data} = Packet.encode_request(packet) :gen_udp.send(sk, host, port, data) end end diff --git a/lib/radius/dict.ex b/lib/radius/dict.ex index f2e5e9a..f2cb4fc 100644 --- a/lib/radius/dict.ex +++ b/lib/radius/dict.ex @@ -215,7 +215,7 @@ defmodule Radius.Dict do |> String.to_charlist() |> tokenlize! |> parse! - |> (&process_dict(ctx, &1)).() + |> then(&process_dict(ctx, &1)) rescue e in ParserError -> reraise %{e | file: path}, __STACKTRACE__ e -> reraise e, __STACKTRACE__ diff --git a/lib/radius/packet.ex b/lib/radius/packet.ex index 465146c..adc407a 100644 --- a/lib/radius/packet.ex +++ b/lib/radius/packet.ex @@ -1,11 +1,21 @@ defmodule Radius.Packet do require Logger + alias __MODULE__ alias Radius.Dict.Attribute alias Radius.Dict.Vendor alias Radius.Dict.Value alias Radius.Dict.EntryNotFoundError + @type t :: %{ + code: String.t(), + id: integer(), + length: integer(), + auth: binary(), + attrs: keyword(), + raw: iolist(), + secret: binary() + } defstruct code: nil, id: nil, length: nil, @@ -196,26 +206,56 @@ defmodule Radius.Packet do ipaddr :: {a,b,c,d} | {a,b,c,d,e,f,g,h} """ + @deprecated "Use encode_request/1-2 or encode_reply/1-2 instead" def encode(packet, options \\ []) do - sign? = options |> Keyword.get(:sign, false) - raw? = options |> Keyword.get(:raw, false) - - {auth, reply?} = + packet = if packet.auth == nil do - {:crypto.strong_rand_bytes(16), false} + encode_request(packet, options) else - {packet.auth, true} + encode_reply(packet, packet.auth, options) end - packet = %{packet | auth: auth} + packet.raw + end + + @doc """ + Encode the request packet into an iolist and put the result in the `:raw` key. The `:auth` key will contain + the authenticator used on the request. + """ + @spec encode_request(packet :: Packet.t(), options :: keyword()) :: Packet.t() + def encode_request(packet, options \\ []) do + packet = %{packet | auth: :crypto.strong_rand_bytes(16)} + {header, attrs} = encode_packet(packet, options) + + %{packet | raw: [header, attrs]} + end + + @doc """ + Encode the reply packet into an iolist and put the result in the `:raw` key. The `:auth` key needs + to be filled with the authenticator of the request packet. + """ + @spec encode_reply( + packet :: Packet.t(), + request_authenticator :: binary(), + options :: keyword() + ) :: Packet.t() + def encode_reply(packet, request_authenticator, options \\ []) do + packet = %{packet | auth: request_authenticator} + {header, attrs} = encode_packet(packet, options) + + resp_auth = :crypto.hash(:md5, [header, attrs, packet.secret]) + + header = <> + + %{packet | auth: resp_auth, raw: [header, attrs]} + end + + defp encode_packet(packet, options) do + sign? = options |> Keyword.get(:sign, false) packet = if sign? do - attrs = - packet.attrs ++ - [ - {"Message-Authenticator", <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>} - ] + attrs = [{"Message-Authenticator", <<0::size(128)>>} | packet.attrs] %{packet | attrs: attrs} else @@ -225,35 +265,23 @@ defmodule Radius.Packet do attrs = encode_attrs(packet) code = encode_code(packet.code) - length = 20 + :erlang.iolist_size(attrs) - header = <> + length = 20 + IO.iodata_length(attrs) + header = <> attrs = if sign? do - signature = :crypto.mac(:hmac, :md5, packet.secret, [header, attrs]) - [last | attrs] = attrs |> Enum.reverse() - crop_len = byte_size(last) - 16 - last = <> - [last | attrs] |> Enum.reverse() + signature = message_authenticator(packet.secret, [header, attrs]) + [<> | rest_attrs] = attrs + [<> | rest_attrs] else attrs end - header = - if reply? and raw? == false do - resp_auth = - :crypto.hash_init(:md5) - |> :crypto.hash_update(header) - |> :crypto.hash_update(attrs) - |> :crypto.hash_update(packet.secret) - |> :crypto.hash_final() - - <> - else - header - end + {header, attrs} + end - [header, attrs] + defp message_authenticator(secret, msg) do + :crypto.mac(:hmac, :md5, secret, msg) end defp encode_attrs(%{attrs: a} = ctx) do @@ -422,47 +450,45 @@ defmodule Radius.Packet do @doc """ Return the value of a given attribute, if found, or default otherwise. """ - def get_attr(packet, attr_name, default \\ nil) do - result = - packet.attrs - |> Enum.find(default, fn - {^attr_name, _} -> true - _ -> false - end) - - case result do - {_, value} -> value - _ -> nil - end + def get_attr(packet, attr_name) do + for {^attr_name, value} <- packet.attrs, do: value end @doc """ Verify if the packet signature is valid. + + (https://www.ietf.org/rfc/rfc2865.txt) + (https://www.ietf.org/rfc/rfc2869.txt) """ def verify(packet) do - # TODO: this code is going to fail when validating replies - sig1 = - packet - |> Radius.Packet.get_attr("Message-Authenticator") + verify(packet, packet.auth) + end - if sig1 != nil do - attrs = - packet.attrs - |> Enum.filter(fn {k, _} -> k != "Message-Authenticator" end) + def verify(packet, request_authenticator) do + case Radius.Packet.get_attr(packet, "Message-Authenticator") do + [sig1] -> + {header, attrs} = encode_packet(%{packet | auth: request_authenticator}, []) + resp_auth = :crypto.hash(:md5, [header, attrs, packet.secret]) - raw = - %{packet | attrs: attrs} - |> Radius.Packet.encode(raw: true, sign: true) - |> IO.iodata_to_binary() + attrs = + Enum.map(packet.attrs, fn + {"Message-Authenticator", _} -> {"Message-Authenticator", <<0::size(128)>>} + attr -> attr + end) - crop_len = byte_size(raw) - 16 - <<_::bytes-size(crop_len), sig2::binary>> = raw + packet = %{packet | attrs: attrs} + {header, attrs} = encode_packet(packet, []) + <> = header + sign_header = <> + sig2 = message_authenticator(packet.secret, [sign_header, attrs]) - sig1 == sig2 - else - false + (packet.auth == request_authenticator or packet.auth == resp_auth) and sig1 == sig2 + + _ -> + {header, attrs} = encode_packet(%{packet | auth: request_authenticator}, []) + resp_auth = :crypto.hash(:md5, [header, attrs, packet.secret]) + + packet.auth == request_authenticator or packet.auth == resp_auth end end end - -# defmodule Packet diff --git a/lib/radius/util.ex b/lib/radius/util.ex index efbbd4b..b3f9d53 100644 --- a/lib/radius/util.ex +++ b/lib/radius/util.ex @@ -34,38 +34,31 @@ defmodule Radius.Util do defp hash_xor(input, hash, secret, acc, opts \\ []) defp hash_xor(<<>>, _, _, acc, _) do - acc |> Enum.reverse() |> :erlang.iolist_to_binary() + acc |> Enum.reverse() |> IO.iodata_to_binary() end - defp hash_xor(<>, hash, secret, acc, opts) do + @block_binary_size 16 + @block_size @block_binary_size * 8 + defp hash_xor(<>, hash, secret, acc, opts) do hash = :crypto.hash(:md5, secret <> hash) xor_block = binary_xor(block, hash) next = if(opts |> Keyword.get(:reverse, false), do: block, else: xor_block) hash_xor(rest, next, secret, [xor_block | acc]) end - defp binary_xor(x, y) when byte_size(x) == byte_size(y) do - s = byte_size(x) * 8 - <> = x - <> = y + defp binary_xor(<>, <>) do z = bxor(x, y) - <> + <> end defp pad_to_16(bin) do - pad_to_16(bin, []) - |> Enum.reverse() - |> :erlang.iolist_to_binary() - end - - defp pad_to_16(bin, acc) when byte_size(bin) == 16, do: [bin | acc] + remainder = Integer.mod(byte_size(bin), 16) - defp pad_to_16(bin, acc) when byte_size(bin) < 16 do - bin = <> - <> = bin - [chunk | acc] + if remainder == 0 do + bin + else + padding = 16 - remainder + <> + end end - - defp pad_to_16(<>, acc), - do: pad_to_16(rest, [chunk | acc]) end diff --git a/test/radius_packet_test.exs b/test/radius_packet_test.exs new file mode 100644 index 0000000..94a54c5 --- /dev/null +++ b/test/radius_packet_test.exs @@ -0,0 +1,153 @@ +defmodule Radius.PacketTest do + use ExUnit.Case, async: true + + @secret "mykey" + @sample_req %Radius.Packet{ + code: "Access-Request", + id: 118, + length: 173, + auth: <<55, 91, 232, 245, 150, 233, 11, 207, 252, 94, 50, 146, 157, 20, 39, 91>>, + attrs: [ + {"NAS-IP-Address", {10, 62, 1, 238}}, + {"NAS-Port", 50001}, + {"NAS-Port-Type", "Ethernet"}, + {"User-Name", "host/drswin7tracyp.drsl.co.uk"}, + {"Called-Station-Id", "00-12-00-E3-41-C1"}, + {"Calling-Station-Id", "B4-99-BA-F2-8A-D6"}, + {"Service-Type", "Framed-User"}, + {"Framed-MTU", 1500}, + {"EAP-Message", + <<2, 0, 0, 34, 1, 104, 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, + 99, 121, 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107>>}, + {"Message-Authenticator", + <<201, 62, 246, 40, 105, 10, 87, 139, 49, 112, 155, 11, 188, 202, 222, 65>>} + ], + raw: nil, + secret: "mykey" + } + @sample_binary_req <<1, 118, 0, 173, 55, 91, 232, 245, 150, 233, 11, 207, 252, 94, 50, 146, 157, + 20, 39, 91, 4, 6, 10, 62, 1, 238, 5, 6, 0, 0, 195, 81, 61, 6, 0, 0, 0, 15, + 1, 31, 104, 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, + 97, 99, 121, 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107, 30, + 19, 48, 48, 45, 49, 50, 45, 48, 48, 45, 69, 51, 45, 52, 49, 45, 67, 49, 31, + 19, 66, 52, 45, 57, 57, 45, 66, 65, 45, 70, 50, 45, 56, 65, 45, 68, 54, 6, + 6, 0, 0, 0, 2, 12, 6, 0, 0, 5, 220, 79, 36, 2, 0, 0, 34, 1, 104, 111, 115, + 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, 99, 121, 112, 46, + 100, 114, 115, 108, 46, 99, 111, 46, 117, 107, 80, 18, 201, 62, 246, 40, + 105, 10, 87, 139, 49, 112, 155, 11, 188, 202, 222, 65>> + @sample_rep %Radius.Packet{ + code: "Access-Accept", + id: 118, + length: 173, + auth: nil, + attrs: [ + {"NAS-IP-Address", {10, 62, 1, 238}}, + {"NAS-Port", 50001}, + {"NAS-Port-Type", "Ethernet"}, + {"User-Name", "host/drswin7tracyp.drsl.co.uk"}, + {"Called-Station-Id", "00-12-00-E3-41-C1"}, + {"Calling-Station-Id", "B4-99-BA-F2-8A-D6"}, + {"Service-Type", "Framed-User"}, + {"Framed-MTU", 1500}, + {"EAP-Message", + <<2, 0, 0, 34, 1, 104, 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, + 99, 121, 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107>>} + ], + raw: nil, + secret: "mykey" + } + @sample_binary_rep <<2, 118, 0, 155, 25, 149, 189, 198, 178, 14, 197, 28, 131, 240, 157, 146, + 150, 38, 53, 105, 4, 6, 10, 62, 1, 238, 5, 6, 0, 0, 195, 81, 61, 6, 0, 0, + 0, 15, 1, 31, 104, 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, + 116, 114, 97, 99, 121, 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, + 107, 30, 19, 48, 48, 45, 49, 50, 45, 48, 48, 45, 69, 51, 45, 52, 49, 45, + 67, 49, 31, 19, 66, 52, 45, 57, 57, 45, 66, 65, 45, 70, 50, 45, 56, 65, 45, + 68, 54, 6, 6, 0, 0, 0, 2, 12, 6, 0, 0, 5, 220, 79, 36, 2, 0, 0, 34, 1, 104, + 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, 99, 121, + 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107>> + @sample_binary_rep_signed <<2, 118, 0, 173, 132, 213, 98, 44, 33, 151, 126, 7, 160, 110, 91, 18, + 56, 125, 67, 245, 80, 18, 27, 203, 27, 162, 52, 156, 30, 25, 241, + 43, 80, 77, 28, 109, 228, 77, 4, 6, 10, 62, 1, 238, 5, 6, 0, 0, 195, + 81, 61, 6, 0, 0, 0, 15, 1, 31, 104, 111, 115, 116, 47, 100, 114, + 115, 119, 105, 110, 55, 116, 114, 97, 99, 121, 112, 46, 100, 114, + 115, 108, 46, 99, 111, 46, 117, 107, 30, 19, 48, 48, 45, 49, 50, 45, + 48, 48, 45, 69, 51, 45, 52, 49, 45, 67, 49, 31, 19, 66, 52, 45, 57, + 57, 45, 66, 65, 45, 70, 50, 45, 56, 65, 45, 68, 54, 6, 6, 0, 0, 0, + 2, 12, 6, 0, 0, 5, 220, 79, 36, 2, 0, 0, 34, 1, 104, 111, 115, 116, + 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, 99, 121, 112, + 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107>> + + test "decode request" do + packet = Radius.Packet.decode(@sample_binary_req, @secret) + assert packet.code == @sample_req.code + assert packet.id == @sample_req.id + assert packet.length == @sample_req.length + + assert packet.auth == @sample_req.auth + end + + test "encode request - deprecated" do + # cut authenticator as it will be generated on each encoding + <> = + @sample_req + |> Map.put(:auth, nil) + |> Radius.Packet.encode() + |> IO.iodata_to_binary() + + <> = @sample_binary_req + assert <> == <> + end + + test "encode reply - deprecated" do + reply = + @sample_rep + |> Map.put(:auth, @sample_req.auth) + |> Radius.Packet.encode() + |> IO.iodata_to_binary() + + assert reply == @sample_binary_rep + end + + test "encode request" do + # cut authenticator as it will be generated on each encoding + <> = + @sample_req |> Radius.Packet.encode_request() |> Map.get(:raw) |> IO.iodata_to_binary() + + <> = @sample_binary_req + assert <> == <> + end + + test "encode reply" do + reply = + @sample_rep + |> Radius.Packet.encode_reply(@sample_req.auth) + |> Map.get(:raw) + |> IO.iodata_to_binary() + + assert reply == @sample_binary_rep + end + + test "encode and sign reply" do + reply = + @sample_rep + |> Radius.Packet.encode_reply(@sample_req.auth, sign: true) + |> Map.get(:raw) + |> IO.iodata_to_binary() + + assert reply == @sample_binary_rep_signed + end + + test "verify (message) authenticator signature on request" do + assert Radius.Packet.verify(@sample_req) + assert Radius.Packet.verify(%{@sample_req | attrs: []}) + refute Radius.Packet.verify(%{@sample_req | id: 14}) + end + + test "verify message authenticator signature on reply" do + packet = Radius.Packet.decode(@sample_binary_rep_signed, @secret) + assert Radius.Packet.verify(packet, @sample_req.auth) + refute Radius.Packet.verify(packet) + refute Radius.Packet.verify(%{packet | id: 14}, @sample_req.auth) + refute Radius.Packet.verify(%{packet | attrs: []}, @sample_req.auth) + end +end diff --git a/test/radius_util_test.exs b/test/radius_util_test.exs index 38310f5..bc8b79b 100644 --- a/test/radius_util_test.exs +++ b/test/radius_util_test.exs @@ -1,5 +1,5 @@ defmodule Radius.UtilTest do - use ExUnit.Case + use ExUnit.Case, async: true @a <<131, 203, 230, 68, 225, 38, 170, 174, 240, 200, 112, 101, 138, 46, 93, 66>> @e <<47, 30, 188, 38, 120, 163, 223, 41, 29, 48, 100, 113, 255, 68, 152, 156>> @@ -45,43 +45,4 @@ defmodule Radius.UtilTest do e = Radius.Util.encrypt_rfc2868(p, @s, @a) assert Radius.Util.decrypt_rfc2868(e, @s, @a) == p end - - @secret "mykey" - @sample_packet <<1, 118, 0, 173, 55, 91, 232, 245, 150, 233, 11, 207, 252, 94, 50, 146, 157, 20, - 39, 91, 4, 6, 10, 62, 1, 238, 5, 6, 0, 0, 195, 81, 61, 6, 0, 0, 0, 15, 1, 31, - 104, 111, 115, 116, 47, 100, 114, 115, 119, 105, 110, 55, 116, 114, 97, 99, - 121, 112, 46, 100, 114, 115, 108, 46, 99, 111, 46, 117, 107, 30, 19, 48, 48, - 45, 49, 50, 45, 48, 48, 45, 69, 51, 45, 52, 49, 45, 67, 49, 31, 19, 66, 52, 45, - 57, 57, 45, 66, 65, 45, 70, 50, 45, 56, 65, 45, 68, 54, 6, 6, 0, 0, 0, 2, 12, - 6, 0, 0, 5, 220, 79, 36, 2, 0, 0, 34, 1, 104, 111, 115, 116, 47, 100, 114, 115, - 119, 105, 110, 55, 116, 114, 97, 99, 121, 112, 46, 100, 114, 115, 108, 46, 99, - 111, 46, 117, 107, 80, 18, 201, 62, 246, 40, 105, 10, 87, 139, 49, 112, 155, - 11, 188, 202, 222, 65>> - - test "Message-Authenticator" do - packet = Radius.Packet.decode(@sample_packet, @secret) - raw = packet |> Radius.Packet.encode(raw: true) |> IO.iodata_to_binary() - assert raw == @sample_packet - - attrs = - packet.attrs - |> Enum.filter(fn {k, _} -> k != "Message-Authenticator" end) - - signed = - %{packet | attrs: attrs} - |> Radius.Packet.encode(sign: true, raw: true) - |> IO.iodata_to_binary() - |> Radius.Packet.decode(@secret) - - sig1 = packet |> Radius.Packet.get_attr("Message-Authenticator") - sig2 = signed |> Radius.Packet.get_attr("Message-Authenticator") - - assert sig1 == sig2 - - raw = signed |> Radius.Packet.encode(raw: true) |> IO.iodata_to_binary() - - assert raw == @sample_packet - - assert Radius.Packet.verify(packet) - end end