Querying DNSSEC in Elixir/Erlang
At the beginning of this year I had to figure out how to work with DNS resource records types (DNS RR types) like DNSKEY and DS that are not natively supported in Elixir/Erlang.
Background
Depending on who you ask, the need for DNSSEC might be a controversial topic, but at least in Switzerland, there is a big push for adding DNSSEC to all .ch domains.
At work, we built an Elixir service that coordinates the provisioning of the DNSKEYs for our customers on our nameservers and then sends those keys to the .ch registry.
Because of the different ways DNSSEC can be provisioned, our service needs to check for the presence of DNSSEC keys and DS records using DNS. At first, we did the check using dig
and System.cmd/3
because of time constraints when we built the service.
This worked at the time, but the fact that we had to shell out for just that bit always bothered me, so I did some digging to find out how we could do it all in Elixir.
The inet_res Module
Thanks to Erlang and OTP querying DNS records in Elixir is very easy. The functionality to make DNS queries is provided by the :inet_res
module and for the most part we can use an atom representation of the RR type to get the corresponding DNS results.
For example querying the A record of a domain, is as simple as :inet_res.resolve(~c"dahlheim.ch", :in, :a)
.
iex(1)> :inet_res.resolve(~c"dahlheim.ch", :in, :a)
{:ok,
{:dns_rec, {:dns_header, 1, true, :query, false, false, true, true, false, 0},
[{:dns_query, ~c"dahlheim.ch", :a, :in, false}],
[
{:dns_rr, ~c"dahlheim.ch", :a, :in, 0, 8216, {149, 126, 4, 11}, :undefined,
[], false}
], [], []}}
Under the hood the :inet_res
module maps the atom to the integer representation of the RR type. Currently :inet_res
supports a few
RR types that should be enough for normal day to day DNS use.
% Supported RR types as of erlang/otp 26.0
dns_rr_type() =
a | aaaa | caa | cname | gid | hinfo | ns | mb | md | mg |
mf | minfo | mx | naptr | null | ptr | soa | spf | srv | txt |
uid | uinfo | unspec | uri | wks
But what if we want to query an RR type that is not part of the list? Like DNSKEY or DS?
Well… the short answer is we get an error.
iex(2)> :inet_res.resolve(~c"dahlheim.ch", :in, :dnskey)
** (CaseClauseError) no case clause matching: :dnskey
(kernel 9.0.2) inet_dns.erl:396: :inet_dns.encode_type/1
(kernel 9.0.2) inet_dns.erl:302: :inet_dns.encode_query_section/3
(kernel 9.0.2) inet_dns.erl:275: :inet_dns.encode/1
(kernel 9.0.2) inet_res.erl:694: :inet_res.make_query/5
(kernel 9.0.2) inet_res.erl:657: :inet_res.make_query/4
(kernel 9.0.2) inet_res.erl:628: :inet_res.res_query/5
(kernel 9.0.2) inet_res.erl:148: :inet_res.resolve/5
iex:33: (file)
The function inet_dns.encode_type/1
, which is used internally by :inet_res
, does not know how to map the :dnskey
atom to the corresponding integer in the RR type table. Some might give up right here and chose to use System.cmd
and dig
because it just works, like we did when we first implemented our service.
Some might think, they have to implement their own DNS client using gen_udp
.
Fortunately you don’t have to do either of those things. But let’s first delve a bit deeper into the behavior of the :inet_res
module.
Raw RR type IDs
One undocumented feature of the :inet_res
module is that it allows you to pass the integer ID of the RR type directly instead of an atom.
For example in theory we could query the A record of the domain dahlheim.ch
like this.
iex(3)> :inet_res.resolve(~c"dahlheim.ch", :in, 1)
In cases where the RR types is supported it fetches the correct results but causes a :noquery
error, because the RR type in the
answer header gets converted back to an atom and inet_res.resolve
checks if the header of the question is equal to answer header.
iex(3)> :inet_res.resolve(~c"dahlheim.ch", :in, 1)
{:error,
{:noquery,
{:dns_rec,
{:dns_header, 2, true, :query, false, false, true, true, false, 0},
[{:dns_query, ~c"dahlheim.ch", :a, :in, false}],
[
{:dns_rr, ~c"dahlheim.ch", :a, :in, 0, 8421, {149, 126, 4, 11}, :undefined,
[], false}
], [], []}}}
But besides it being wrapped in a :noquery
tuple the content of the answer is correct and we did not get an CaseClauseError
error.
That is a big improvement.
Querying DNSKEY records
Now that we know that we can pass integers as query type instead of the atom, we can construct our new DNSKEY query, all that is missing is a quick lookup of the RR type ID of the DNSKEY type, which is 48.
Our new query will look like this:
iex(4)> :inet_res.resolve(~c"dahlheim.ch", :in, 48)
And once we submit this query we get an {:ok, dns_rec}
answer back, instead of the match error from before.
iex(4)> :inet_res.resolve(~c"dahlheim.ch", :in, 48)
{:ok,
{:dns_rec, {:dns_header, 3, true, :query, false, false, true, true, false, 0},
[{:dns_query, ~c"dahlheim.ch", 48, :in, false}],
[
{:dns_rr, ~c"dahlheim.ch", 48, :in, 0, 75598,
<<1, 0, 3, 13, 242, 30, 156, 252, 222, 57, 116, 223, 132, 191, 19, 155, 37,
12, 64, 136, 235, 7, 131, 136, 123, 28, 153, 210, 101, 48, 230, 44, 0,
176, 186, 246, ...>>, :undefined, [], false},
{:dns_rr, ~c"dahlheim.ch", 48, :in, 0, 75598,
<<1, 1, 3, 13, 10, 191, 218, 31, 120, 61, 229, 200, 246, 58, 127, 56, 155,
139, 113, 10, 183, 53, 177, 21, 243, 105, 205, 47, 43, 139, 93, 124, 171,
22, 121, ...>>, :undefined, [], false}
], [], []}}
Because inet_res
still does not know anything about RR type 48 we get the raw binary back, also known as wire format. We have to write our own parser for DNSKEY records to turn ot into the more humany friendly presentation format. Thankfully pattern matching makes this trivial.
Decoding the DNSKEY data
To decode the DNSKEY data from wire format to presentation format we first have to know the structure of the binary wire format which is documented in RFC 4034 Section 2.1.
1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Flags | Protocol | Algorithm |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
/ Public Key /
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Most of the time a wire format diagram like this translates into a function head pattern match very nicely. One way to represent this diagram in Elixir would be a function like this.
defmodule DNSSEC do
def dnskey_from_binary(<<flags::16, protocol::8, algorithm::8, public_key::binary>>) do
:todo_presentation_format
end
end
If you’re unfamiliar with binary pattern matching, this pattern match uses the Shortcut Syntax.
What we are doing is telling Elixir to interpret the first 16 bits of the binary as one unsigned integer and assign it to a flags
variable. Then we tell Elixir that the next 8 bits should also be interpreted as an unsigned integer, and the result should be assigned to the protocol
variable. We do the same for the next 8 bits and assign them to the algorithm
variable. Finally, we tell Elixir that the remaining bits of the binary should be assigned to the public_key
variable without interpreting them.
Once we have the diffrent parts of the DNSKEY, we need to turn it into something we can read. The Section 2.2 of the RFC describes the presentation format of the DNSKEY.
I won’t go into details because our pattern match already did most of the decoding for us, the only thing left for us to do is to base64 encode the public_key
part.
Our final function will look like this.
iex(5)> defmodule DNSSEC do
def dnskey_from_binary(<<flags::16, protocol::8, algorithm::8, public_key::binary>>) do
{flags, protocol, algorithm, Base.encode64(public_key)}
end
end
We return the DNSKEY as a four element tuple to match the return types of the other RR types in inet_res
like IPv4, IPv6 and MX, but we could have chosen a Struct or a Map as well.
Time to try it! We are going to use the :inet_res.lookup/3
function because it makes piping the results to Enum.map/2
easier.
iex(6)> :inet_res.lookup(~c"dahlheim.ch", :in, 48) |> Enum.map(&DNSSEC.dnskey_from_binary/1)
[
{256, 3, 13,
"8h6c/N45dN+EvxObJQxAiOsHg4h7HJnSZTDmLACwuvbUC+BlZSCxI6AAgu3cfaeii4wXvUk7btuQKURQ3Cx7Qg=="},
{257, 3, 13,
"Cr/aH3g95cj2On84m4txCrc1sRXzac0vK4tdfKsWeW9QFVAwjf8Xj3hNhClhPGVPRxT6IlELtngJvQPA9HPDeg=="}
]
This looks much better don’t you think? We can do the same for DS RR type and in the end our DNSSEC module will look similar to this.
defmodule DNSSEC do
def dnskey_from_binary(<<flags::16, protocol::8, algorithm::8, public_key::binary>>) do
{flags, protocol, algorithm, Base.encode64(public_key)}
end
def ds_from_binary(<<type::16, algorithm::8, digest_type::8, digest::binary>>) do
{type, algorithm, digest_type, Base.encode64(digest)}
end
end
Making it reusable
Although it’s nice to know how to implement it yourself most of the time you probably just want to use a library.
I’m currently working on one that implements both DNSSEC and DS decoding as well as the keytag calculation function needed to match a DNSKEY to an DS entry.
You can find it on my GitHub hdahlheim/dnssec_ex. There is a lot of work left to allow us to fully validate DNSSEC in Elixir/Erlang, some of which needs to be done in Erlang/OTP itself.
My longtime goal is to contribute DNSSEC support to Erlang/OTP directly.