merge: pr-10b-featdns-dnssec-sign-on-publish-serve-rrsigdnskey

This commit is contained in:
maxfield 2026-08-02 01:54:36 -04:00
commit 2651f20d3a
12 changed files with 1508 additions and 64 deletions

View file

@ -361,13 +361,34 @@ defmodule Elektrine.DNS do
@doc """ @doc """
Enables DNSSEC key generation for a zone (KSK + ZSK, ECDSA P-256). Enables DNSSEC key generation for a zone (KSK + ZSK, ECDSA P-256).
Stores encrypted private keys and zone flags only does not change the When the global `DNS_DNSSEC_ENABLED` serve flag is on, the authority cache is
authoritative answer path (see PR 10b for signing/serve). refreshed so the zone is pre-signed (DNSKEY/RRSIG) for publish.
""" """
def enable_zone_dnssec(%Zone{} = zone), do: Dnssec.enable_zone(zone) def enable_zone_dnssec(%Zone{} = zone) do
case Dnssec.enable_zone(zone) do
{:ok, enabled} ->
refresh_authority_cache()
{:ok, enabled}
error ->
error
end
end
def enable_zone_dnssec(_), do: {:error, :invalid_zone} def enable_zone_dnssec(_), do: {:error, :invalid_zone}
@doc """
Global DNSSEC serve-path switch (`DNS_DNSSEC_ENABLED`).
When false (default), no RRSIGs or managed DNSKEY answers are served even if
a zone has keys. When true, zones with `dnssec_enabled` and prepared keys
serve DNSKEY/RRSIG for signed types.
"""
def dnssec_enabled? do
Application.get_env(:elektrine, :dns, [])
|> Keyword.get(:dnssec_enabled, false) == true
end
@doc "DNSSEC status, public key metadata, and DS records for registrar export." @doc "DNSSEC status, public key metadata, and DS records for registrar export."
def zone_dnssec_status(%Zone{} = zone), do: Dnssec.status(zone) def zone_dnssec_status(%Zone{} = zone), do: Dnssec.status(zone)

View file

@ -2,8 +2,8 @@ defmodule Elektrine.DNS.Dnssec do
@moduledoc """ @moduledoc """
DNSSEC key generation and DS export for managed zones. DNSSEC key generation and DS export for managed zones.
PR 10a: generates and stores keys, exports DS for the parent registrar. Key material and DS export live here. RRSet signing / RRSIG serve is in
Does **not** sign RRSets or change the authoritative answer path. `Elektrine.DNS.Dnssec.Signer`, gated by the global `DNS_DNSSEC_ENABLED` flag.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false

View file

@ -0,0 +1,654 @@
defmodule Elektrine.DNS.Dnssec.Signer do
@moduledoc """
Pre-signs zone RRSets (ECDSA P-256 / algorithm 13) for authoritative serve.
Called when zones are published into `ZoneCache`. Signatures are attached as a
virtual `dnssec_bundle` on the zone so the query path never performs crypto
on the hot path.
## Matching answers to signatures
Each signed RRset stores a digest of its canonical RDATA. Query only attaches
an RRSIG when the answer RRset digests equal the signed digest so health
failover (or any other answer mutator) that shrinks an A/AAAA set omits the
RRSIG instead of serving a non-covering signature (BOGUS for validators).
## ALIAS limitations
Proxied ALIAS/CNAME are signed as the edge A/AAAA answers Query returns.
Non-proxied ALIAS that flattens to **in-zone** A/AAAA is signed at publish
time against those local targets. ALIAS that resolves **outside** the zone
(runtime external lookup) is left unsigned document that combination as
unsupported until a later phase.
"""
require Logger
alias Elektrine.DNS
alias Elektrine.DNS.Dnssec
alias Elektrine.DNS.DnssecKey
alias Elektrine.DNS.Packet
alias Elektrine.DNS.Record
alias Elektrine.DNS.Zone
# Common types signed in PR 10b. Unsupported / dynamic types are left unsigned.
@signed_types ~w(a aaaa ns soa mx txt cname caa srv sshfp tlsa ds dnskey https svcb)a
@class_in 1
@signature_validity_seconds 30 * 24 * 60 * 60
@signature_inception_skew_seconds 3600
@type rrsig_record :: map()
@type dnssec_bundle :: %{
dnskeys: [map()],
rrsigs: %{{String.t(), atom()} => [rrsig_record()]},
rrset_digests: %{{String.t(), atom()} => binary()},
signed_at: DateTime.t()
}
@doc """
Prepares a zone for DNSSEC serve by building DNSKEY answers and RRSIGs.
Returns the zone unchanged when keys are missing or signing fails for the
entire zone. Individual unsignable RRSets are skipped without raising.
On success, `dnssec_keys` is cleared on the returned zone so decrypted
private material is not retained in ZoneCache/ETS (only `dnssec_bundle`).
"""
@spec prepare_zone(Zone.t()) :: Zone.t()
def prepare_zone(%Zone{} = zone) do
keys = active_keys(zone)
if keys == [] do
drop_private_keys(zone)
else
case build_bundle(zone, keys) do
{:ok, bundle} ->
zone
|> Map.put(:dnssec_bundle, bundle)
|> drop_private_keys()
{:error, reason} ->
Logger.warning("DNSSEC prepare skipped for zone #{zone.domain}: #{inspect(reason)}")
drop_private_keys(zone)
end
end
end
def prepare_zone(zone), do: zone
@doc "Whether the zone has a prepared signing bundle ready to serve."
@spec prepared?(Zone.t()) :: boolean()
def prepared?(%Zone{dnssec_bundle: %{dnskeys: keys, rrsigs: sigs}})
when is_list(keys) and map_size(sigs) >= 0 and keys != [],
do: true
def prepared?(_), do: false
@doc "Synthesized apex DNSKEY records from the prepared bundle (or empty)."
@spec dnskey_records(Zone.t()) :: [map()]
def dnskey_records(%Zone{dnssec_bundle: %{dnskeys: dnskeys}}) when is_list(dnskeys), do: dnskeys
def dnskey_records(_), do: []
@doc "RRSIG records covering `{owner, type}` from the prepared bundle."
@spec rrsigs_for(Zone.t(), String.t(), atom() | String.t()) :: [rrsig_record()]
def rrsigs_for(%Zone{dnssec_bundle: %{rrsigs: rrsigs}}, owner, type)
when is_map(rrsigs) and is_binary(owner) do
key = {normalize_name(owner), normalize_type(type)}
Map.get(rrsigs, key, [])
end
def rrsigs_for(_, _, _), do: []
@doc """
RRSIGs for `{owner, type}` only when `records` match the signed RRset digest.
Prefer this on the query path so mutators (e.g. health failover) never attach
a signature that no longer covers the answer.
"""
@spec rrsigs_for_matching(Zone.t(), String.t(), atom() | String.t(), [map()]) :: [
rrsig_record()
]
def rrsigs_for_matching(zone, owner, type, records)
when is_binary(owner) and is_list(records) do
if covers_rrset?(zone, owner, type, records) do
rrsigs_for(zone, owner, type)
else
[]
end
end
@doc """
True when the answer RRset's canonical RDATA digest matches what was signed.
"""
@spec covers_rrset?(Zone.t(), String.t(), atom() | String.t(), [map()]) :: boolean()
def covers_rrset?(%Zone{dnssec_bundle: %{rrset_digests: digests}}, owner, type, records)
when is_map(digests) and is_binary(owner) and is_list(records) do
key = {normalize_name(owner), normalize_type(type)}
case Map.fetch(digests, key) do
{:ok, expected} ->
case rrset_digest(normalize_type(type), records) do
{:ok, ^expected} -> true
_ -> false
end
:error ->
false
end
end
def covers_rrset?(_, _, _, _), do: false
@doc false
@spec sign_rrset(String.t(), atom(), [map()], DnssecKey.t() | map(), keyword()) ::
{:ok, rrsig_record()} | {:error, term()}
def sign_rrset(owner, type, records, key, opts \\ [])
when is_binary(owner) and is_atom(type) and is_list(records) do
with {:ok, private_key} <- load_private_key(key),
{:ok, rdata_list} <- encode_rrset_rdatas(type, records) do
now = Keyword.get(opts, :now, DateTime.utc_now() |> DateTime.truncate(:second))
original_ttl = Keyword.get(opts, :original_ttl) || max_ttl(records, 300)
inception = DateTime.to_unix(now) - @signature_inception_skew_seconds
expiration = DateTime.to_unix(now) + @signature_validity_seconds
signer = Keyword.get(opts, :signer) || owner_zone_apex(owner, opts)
labels = label_count(owner)
type_covered = Packet.type_number(type)
algorithm = Map.get(key, :algorithm) || Dnssec.default_algorithm()
key_tag = Map.fetch!(key, :key_tag)
signer_wire = Dnssec.encode_domain_name(signer)
rrsig_prefix =
<<type_covered::16, algorithm::8, labels::8, original_ttl::32, expiration::32,
inception::32, key_tag::16, signer_wire::binary>>
owner_wire = Dnssec.encode_domain_name(owner)
rrset_wire =
rdata_list
|> Enum.map(fn rdata ->
<<owner_wire::binary, type_covered::16, @class_in::16, original_ttl::32,
byte_size(rdata)::16, rdata::binary>>
end)
|> Enum.sort()
|> IO.iodata_to_binary()
to_sign = rrsig_prefix <> rrset_wire
der_sig = :public_key.sign(to_sign, :sha256, private_key)
case der_ecdsa_to_raw(der_sig) do
{:ok, raw_sig} ->
{:ok,
%{
host: normalize_name(owner),
type: :rrsig,
ttl: original_ttl,
type_covered: type,
algorithm: algorithm,
labels: labels,
original_ttl: original_ttl,
expiration: expiration,
inception: inception,
key_tag: key_tag,
signer: normalize_name(signer),
signature: raw_sig,
content: Base.encode64(raw_sig)
}}
error ->
error
end
end
end
@doc false
@spec verify_rrsig(rrsig_record(), [map()], binary() | String.t()) :: boolean()
def verify_rrsig(rrsig, records, public_key_wire)
when is_map(rrsig) and is_list(records) and is_binary(public_key_wire) do
with {:ok, rdata_list} <- encode_rrset_rdatas(rrsig.type_covered, records),
{:ok, ec_point, curve} <- public_key_from_wire(public_key_wire) do
type_covered = Packet.type_number(rrsig.type_covered)
owner = rrsig.host
owner_wire = Dnssec.encode_domain_name(owner)
signer_wire = Dnssec.encode_domain_name(rrsig.signer)
rrsig_prefix =
<<type_covered::16, rrsig.algorithm::8, rrsig.labels::8, rrsig.original_ttl::32,
rrsig.expiration::32, rrsig.inception::32, rrsig.key_tag::16, signer_wire::binary>>
rrset_wire =
rdata_list
|> Enum.map(fn rdata ->
<<owner_wire::binary, type_covered::16, @class_in::16, rrsig.original_ttl::32,
byte_size(rdata)::16, rdata::binary>>
end)
|> Enum.sort()
|> IO.iodata_to_binary()
to_verify = rrsig_prefix <> rrset_wire
signature = rrsig_signature_bytes(rrsig)
der = raw_ecdsa_to_der(signature)
# OTP accepts `{ECPoint, Curve}` or a single-element key list for ECDSA.
:public_key.verify(to_verify, :sha256, der, {ec_point, curve})
else
_ -> false
end
rescue
_ -> false
end
defp build_bundle(%Zone{} = zone, keys) do
zsk = Enum.find(keys, &(&1.key_role == "zsk")) || List.first(keys)
ksk = Enum.find(keys, &(&1.key_role == "ksk")) || zsk
apex = normalize_name(zone.domain)
ttl = zone.default_ttl || 300
dnskeys = Enum.map(keys, &dnskey_answer(apex, &1, ttl))
rrsets =
zone
|> collect_public_rrsets()
|> Map.put({apex, :dnskey}, dnskeys)
|> Map.put({apex, :soa}, [Zone.soa_record(zone)])
{rrsigs, digests} =
Enum.reduce(rrsets, {%{}, %{}}, fn {{owner, type}, records}, {sig_acc, dig_acc} ->
if type in @signed_types and records != [] do
signing_key = if type == :dnskey, do: ksk, else: zsk
with {:ok, rrsig} <-
sign_rrset(owner, type, records, signing_key,
signer: apex,
original_ttl: max_ttl(records, ttl)
),
{:ok, digest} <- rrset_digest(type, records) do
{
Map.update(sig_acc, {owner, type}, [rrsig], &[rrsig | &1]),
Map.put(dig_acc, {owner, type}, digest)
}
else
{:error, reason} ->
Logger.debug("DNSSEC skip RRSet #{owner}/#{type} for #{apex}: #{inspect(reason)}")
{sig_acc, dig_acc}
end
else
{sig_acc, dig_acc}
end
end)
{:ok,
%{
dnskeys: dnskeys,
rrsigs: rrsigs,
rrset_digests: digests,
signed_at: DateTime.utc_now() |> DateTime.truncate(:second)
}}
rescue
error ->
{:error, error}
end
defp collect_public_rrsets(%Zone{} = zone) do
apex = normalize_name(zone.domain)
records =
(DNS.zone_onboarding_records(zone) ++ (zone.records || []))
|> Enum.reject(&Record.private?/1)
Enum.reduce(records, %{}, fn record, acc ->
type = normalize_type(Map.get(record, :type))
cond do
type == :alias and Record.proxied?(record) ->
merge_proxied_rrsets(acc, zone, record, apex)
type == :alias ->
# Non-proxied ALIAS: sign in-zone A/AAAA expansions only (external
# flatten targets stay unsigned — runtime lookup is not stable).
merge_local_alias_rrsets(acc, zone, record, apex)
type not in @signed_types ->
acc
Record.proxied?(record) and type in [:a, :aaaa, :cname] ->
merge_proxied_rrsets(acc, zone, record, apex)
true ->
owner = record_owner(zone, record, apex)
answers = [public_answer(zone, record, owner)]
merge_rrset(acc, owner, type, answers)
end
end)
end
defp merge_local_alias_rrsets(acc, zone, record, apex) do
owner = record_owner(zone, record, apex)
target = normalize_name(Map.get(record, :content) || "")
ttl = Map.get(record, :ttl) || zone.default_ttl || 300
if target in ["", owner] do
acc
else
zone_apex = apex
Enum.reduce([:a, :aaaa], acc, fn qtype, inner ->
answers =
(zone.records || [])
|> Enum.filter(fn r ->
record_owner(zone, r, zone_apex) == target and
normalize_type(Map.get(r, :type)) == qtype and
not Record.private?(r) and
not Record.proxied?(r)
end)
|> Enum.map(fn r ->
%{
host: owner,
type: qtype,
content: Map.get(r, :content),
ttl: ttl
}
end)
if answers == [], do: inner, else: merge_rrset(inner, owner, qtype, answers)
end)
end
end
defp merge_proxied_rrsets(acc, zone, record, apex) do
owner = record_owner(zone, record, apex)
type = normalize_type(Map.get(record, :type))
ttl = Map.get(record, :ttl) || zone.default_ttl || 300
case type do
t when t in [:a, :aaaa] ->
contents =
if t == :a,
do: DNS.edge_proxy_ipv4_addresses(),
else: DNS.edge_proxy_ipv6_addresses()
answers =
Enum.map(contents, fn content ->
%{host: owner, type: t, content: content, ttl: ttl}
end)
if answers == [], do: acc, else: merge_rrset(acc, owner, t, answers)
t when t in [:cname, :alias] ->
# Proxied CNAME/ALIAS are answered as edge A/AAAA, so sign those forms.
a_answers =
Enum.map(DNS.edge_proxy_ipv4_addresses(), fn content ->
%{host: owner, type: :a, content: content, ttl: ttl}
end)
aaaa_answers =
Enum.map(DNS.edge_proxy_ipv6_addresses(), fn content ->
%{host: owner, type: :aaaa, content: content, ttl: ttl}
end)
acc
|> then(fn a -> if a_answers == [], do: a, else: merge_rrset(a, owner, :a, a_answers) end)
|> then(fn a ->
if aaaa_answers == [], do: a, else: merge_rrset(a, owner, :aaaa, aaaa_answers)
end)
_ ->
acc
end
end
defp merge_rrset(acc, owner, type, answers) do
Map.update(acc, {owner, type}, answers, &(&1 ++ answers))
end
defp public_answer(zone, record, owner) do
type = normalize_type(Map.get(record, :type))
base = %{
host: owner,
type: type,
ttl: Map.get(record, :ttl) || zone.default_ttl || 300,
content: Map.get(record, :content),
value: Map.get(record, :value) || Map.get(record, :content),
priority: Map.get(record, :priority),
weight: Map.get(record, :weight),
port: Map.get(record, :port),
flags: Map.get(record, :flags),
tag: Map.get(record, :tag),
protocol: Map.get(record, :protocol),
algorithm: Map.get(record, :algorithm),
key_tag: Map.get(record, :key_tag),
digest_type: Map.get(record, :digest_type),
usage: Map.get(record, :usage),
selector: Map.get(record, :selector),
matching_type: Map.get(record, :matching_type)
}
case type do
:ns ->
Map.put(base, :value, Map.get(record, :value) || Map.get(record, :content))
:soa ->
Map.merge(base, %{
mname: Map.get(record, :mname),
rname: Map.get(record, :rname),
serial: Map.get(record, :serial),
refresh: Map.get(record, :refresh),
retry: Map.get(record, :retry),
expire: Map.get(record, :expire),
minimum: Map.get(record, :minimum)
})
_ ->
base
end
end
defp record_owner(zone, record, apex) do
cond do
host = Map.get(record, :host) ->
normalize_name(host)
name = Map.get(record, :name) ->
normalized = normalize_name(name)
cond do
normalized in ["", "@"] -> apex
normalized == apex -> apex
String.ends_with?(normalized, "." <> apex) -> normalized
true -> normalize_name(normalized <> "." <> zone.domain)
end
true ->
apex
end
end
defp dnskey_answer(apex, key, ttl) do
%{
host: apex,
type: :dnskey,
ttl: ttl,
flags: key.flags,
protocol: key.protocol || 3,
algorithm: key.algorithm,
content: key.public_key
}
end
defp active_keys(%Zone{dnssec_keys: keys}) when is_list(keys) do
Enum.filter(keys, &(&1.active == true))
end
defp active_keys(%Zone{id: zone_id}) when is_integer(zone_id) do
Dnssec.list_active_keys(zone_id)
end
defp active_keys(_), do: []
defp encode_rrset_rdatas(type, records) do
rdatas =
Enum.map(records, fn record ->
Packet.rr_rdata(Map.put(record, :type, normalize_type(type)))
end)
if Enum.any?(rdatas, &is_nil/1) do
{:error, :unencodable_rdata}
else
{:ok, rdatas}
end
rescue
error -> {:error, error}
end
# Digest of sorted canonical RDATA bytes — independent of answer order and
# of owner/TTL framing so Query can cheaply detect RRset mutations.
defp rrset_digest(type, records) do
with {:ok, rdatas} <- encode_rrset_rdatas(type, records) do
material =
rdatas
|> Enum.sort()
|> IO.iodata_to_binary()
{:ok, :crypto.hash(:sha256, material)}
end
end
defp drop_private_keys(%Zone{} = zone) do
# Serving only needs dnssec_bundle. Never keep decrypted PEMs in ETS.
%{zone | dnssec_keys: []}
end
defp load_private_key(%{private_key: pem}) when is_binary(pem) do
case :public_key.pem_decode(pem) do
[entry | _] ->
key = :public_key.pem_entry_decode(entry)
{:ok, key}
[] ->
{:error, :invalid_private_key}
end
rescue
_ -> {:error, :invalid_private_key}
end
defp load_private_key(_), do: {:error, :invalid_private_key}
defp public_key_from_wire(<<_::binary-64>> = wire) do
{:ok, {:ECPoint, <<0x04, wire::binary>>}, {:namedCurve, :secp256r1}}
end
defp public_key_from_wire(b64) when is_binary(b64) do
case Base.decode64(b64) do
{:ok, <<_::binary-64>> = wire} ->
public_key_from_wire(wire)
{:ok, _} ->
{:error, :invalid_public_key}
:error ->
{:error, :invalid_public_key}
end
end
defp public_key_from_wire(_), do: {:error, :invalid_public_key}
defp der_ecdsa_to_raw(der) when is_binary(der) do
{:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", der)
{:ok, <<r::unsigned-big-integer-size(256), s::unsigned-big-integer-size(256)>>}
rescue
_ ->
# Manual ASN.1 SEQUENCE { INTEGER r, INTEGER s } parse as fallback.
case der do
<<0x30, _len, 0x02, r_len, r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> ->
{:ok, int_bytes_to_32(r) <> int_bytes_to_32(s)}
_ ->
{:error, :invalid_signature_encoding}
end
end
defp raw_ecdsa_to_der(<<r::unsigned-big-integer-size(256), s::unsigned-big-integer-size(256)>>) do
:public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r, s})
end
defp raw_ecdsa_to_der(other) when is_binary(other) and byte_size(other) == 64 do
<<r::binary-32, s::binary-32>> = other
r_int = :binary.decode_unsigned(r)
s_int = :binary.decode_unsigned(s)
:public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r_int, s_int})
end
defp int_bytes_to_32(bin) do
bin = String.trim_leading(bin, <<0>>)
bin = if bin == <<>>, do: <<0>>, else: bin
pad = 32 - byte_size(bin)
if pad < 0 do
binary_part(bin, byte_size(bin) - 32, 32)
else
:binary.copy(<<0>>, pad) <> bin
end
end
defp rrsig_signature_bytes(%{signature: sig}) when is_binary(sig) and byte_size(sig) == 64,
do: sig
defp rrsig_signature_bytes(%{content: content}) when is_binary(content) do
case Base.decode64(content) do
{:ok, sig} -> sig
:error -> Base.decode64!(content, padding: false)
end
end
defp max_ttl(records, default) do
records
|> Enum.map(&(Map.get(&1, :ttl) || default))
|> Enum.max(fn -> default end)
end
defp label_count(name) do
name
|> normalize_name()
|> String.split(".", trim: true)
|> length()
end
defp owner_zone_apex(owner, opts) do
Keyword.get(opts, :signer) || normalize_name(owner)
end
defp normalize_name(name) when is_binary(name) do
name |> String.trim() |> String.trim_trailing(".") |> String.downcase()
end
defp normalize_name(name), do: name |> to_string() |> normalize_name()
defp normalize_type(type) when is_atom(type), do: type
defp normalize_type(type) when is_binary(type) do
case String.downcase(type) do
"a" -> :a
"aaaa" -> :aaaa
"ns" -> :ns
"soa" -> :soa
"mx" -> :mx
"txt" -> :txt
"cname" -> :cname
"caa" -> :caa
"srv" -> :srv
"sshfp" -> :sshfp
"tlsa" -> :tlsa
"ds" -> :ds
"dnskey" -> :dnskey
"https" -> :https
"svcb" -> :svcb
"rrsig" -> :rrsig
"alias" -> :alias
_ -> :unsupported
end
end
defp normalize_type(type), do: type
end

View file

@ -16,8 +16,10 @@ defmodule Elektrine.DNS.Packet do
txt: 16, txt: 16,
aaaa: 28, aaaa: 28,
srv: 33, srv: 33,
sshfp: 44, # DS before SSHFP so the map documents IANA order; both are supported.
ds: 43, ds: 43,
sshfp: 44,
rrsig: 46,
dnskey: 48, dnskey: 48,
tlsa: 52, tlsa: 52,
svcb: 64, svcb: 64,
@ -267,11 +269,24 @@ defmodule Elektrine.DNS.Packet do
defp decode_name(_, _, _, _), do: {:error, :bad_name} defp decode_name(_, _, _, _), do: {:error, :bad_name}
@doc """
Wire RDATA for a single resource record (no owner/type/class/ttl framing).
Used by the DNSSEC signer for canonical RRSet hashing and by response
encoding. Raises `ArgumentError` when the record cannot be encoded.
"""
def rr_rdata(record) when is_map(record) do
encode_rdata(Map.put(record, :type, normalize_type(Map.get(record, :type))))
end
@doc "IANA RR type number for a known type atom or string."
def type_number(type), do: encode_type(type)
defp encode_record(record, qname_norm) do defp encode_record(record, qname_norm) do
name = encode_owner(record_name(record), qname_norm) name = encode_owner(record_name(record), qname_norm)
type = encode_type(record.type) type = encode_type(record.type)
ttl = Map.get(record, :ttl, 300) ttl = Map.get(record, :ttl, 300)
rdata = encode_rdata(Map.put(record, :type, normalize_type(record.type))) rdata = rr_rdata(record)
<<name::binary, type::16, 1::16, ttl::32, byte_size(rdata)::16, rdata::binary>> <<name::binary, type::16, 1::16, ttl::32, byte_size(rdata)::16, rdata::binary>>
end end
@ -300,11 +315,16 @@ defmodule Elektrine.DNS.Packet do
tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>) tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>)
end end
defp encode_rdata(%{type: :ns, value: value}), do: encode_name(value) defp encode_rdata(%{type: :ns, value: value}) when is_binary(value),
defp encode_rdata(%{type: :cname, content: content}), do: encode_name(content) do: encode_name_canonical(value)
defp encode_rdata(%{type: :ns, content: content}) when is_binary(content),
do: encode_name_canonical(content)
defp encode_rdata(%{type: :cname, content: content}), do: encode_name_canonical(content)
defp encode_rdata(%{type: :mx, content: content, priority: priority}), defp encode_rdata(%{type: :mx, content: content, priority: priority}),
do: <<priority || 10::16, encode_name(content)::binary>> do: <<priority || 10::16, encode_name_canonical(content)::binary>>
defp encode_rdata(%{type: :txt, content: content}), do: encode_txt(content) defp encode_rdata(%{type: :txt, content: content}), do: encode_txt(content)
defp encode_rdata(%{type: :txt, value: value}), do: encode_txt(value) defp encode_rdata(%{type: :txt, value: value}), do: encode_txt(value)
@ -319,7 +339,7 @@ defmodule Elektrine.DNS.Packet do
weight: weight, weight: weight,
port: port port: port
}) do }) do
<<priority || 0::16, weight || 0::16, port || 0::16, encode_name(content)::binary>> <<priority || 0::16, weight || 0::16, port || 0::16, encode_name_canonical(content)::binary>>
end end
defp encode_rdata(%{type: :caa, flags: flags, tag: tag, content: content}) do defp encode_rdata(%{type: :caa, flags: flags, tag: tag, content: content}) do
@ -366,17 +386,29 @@ defmodule Elektrine.DNS.Packet do
<<algorithm || 0::8, digest_type || 0::8, decode_hex_data(content)::binary>> <<algorithm || 0::8, digest_type || 0::8, decode_hex_data(content)::binary>>
end end
defp encode_rdata(%{type: :rrsig} = record) do
type_covered = encode_type(Map.get(record, :type_covered))
algorithm = Map.get(record, :algorithm) || 0
labels = Map.get(record, :labels) || 0
original_ttl = Map.get(record, :original_ttl) || Map.get(record, :ttl) || 0
expiration = Map.get(record, :expiration) || 0
inception = Map.get(record, :inception) || 0
key_tag = Map.get(record, :key_tag) || 0
signer = Map.get(record, :signer) || Map.get(record, :signers_name) || ""
signature = rrsig_signature_binary(record)
<<type_covered::16, algorithm::8, labels::8, original_ttl::32, expiration::32, inception::32,
key_tag::16, encode_name_canonical(signer)::binary, signature::binary>>
end
defp encode_rdata(%{type: type, priority: priority, content: content}) defp encode_rdata(%{type: type, priority: priority, content: content})
when type in [:svcb, :https] do when type in [:svcb, :https] do
case ServiceBinding.encode_rdata(priority || 0, content, &encode_name/1) do case ServiceBinding.encode_rdata(priority || 0, content, &encode_name_canonical/1) do
{:ok, rdata} -> rdata {:ok, rdata} -> rdata
{:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}" {:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}"
end end
end end
defp encode_rdata(%{content: content}) when is_binary(content), do: content
defp encode_rdata(%{value: value}) when is_binary(value), do: value
defp encode_rdata(%{ defp encode_rdata(%{
type: :soa, type: :soa,
mname: mname, mname: mname,
@ -387,8 +419,41 @@ defmodule Elektrine.DNS.Packet do
expire: expire, expire: expire,
minimum: minimum minimum: minimum
}) do }) do
<<encode_name(mname)::binary, encode_name(rname)::binary, serial::32, refresh::32, retry::32, <<encode_name_canonical(mname)::binary, encode_name_canonical(rname)::binary, serial::32,
expire::32, minimum::32>> refresh::32, retry::32, expire::32, minimum::32>>
end
defp encode_rdata(%{content: content}) when is_binary(content), do: content
defp encode_rdata(%{value: value}) when is_binary(value), do: value
defp rrsig_signature_binary(%{signature: sig}) when is_binary(sig) and byte_size(sig) > 0,
do: sig
defp rrsig_signature_binary(%{content: content}) when is_binary(content),
do: decode_base64_data(content)
defp rrsig_signature_binary(_), do: raise(ArgumentError, "RRSIG missing signature bytes")
# Lower-case owner labels for DNSSEC canonical form (RFC 4034 §6.2). Safe for
# non-DNSSEC answers too — DNS names are case-insensitive on the wire.
defp encode_name_canonical(name), do: encode_name(name)
defp encode_name(name) do
normalized =
name
|> to_string()
|> String.trim_trailing(".")
|> String.downcase()
labels = String.split(normalized, ".", trim: true)
if byte_size(normalized) > 253 or Enum.any?(labels, &(byte_size(&1) > 63)) do
raise ArgumentError, "DNS name is too long"
end
labels
|> Enum.map_join(fn label -> <<byte_size(label)>> <> label end)
|> Kernel.<>(<<0>>)
end end
defp encode_txt(content) do defp encode_txt(content) do
@ -419,19 +484,6 @@ defmodule Elektrine.DNS.Packet do
end end
end end
defp encode_name(name) do
normalized = name |> to_string() |> String.trim_trailing(".")
labels = String.split(normalized, ".", trim: true)
if byte_size(normalized) > 253 or Enum.any?(labels, &(byte_size(&1) > 63)) do
raise ArgumentError, "DNS name is too long"
end
labels
|> Enum.map_join(fn label -> <<byte_size(label)>> <> label end)
|> Kernel.<>(<<0>>)
end
defp record_name(%{host: host}), do: host defp record_name(%{host: host}), do: host
defp record_name(%{name: name}), do: name defp record_name(%{name: name}), do: name

View file

@ -2,8 +2,10 @@ defmodule Elektrine.DNS.Query do
@moduledoc false @moduledoc false
import Bitwise import Bitwise
require Logger
alias Elektrine.DNS alias Elektrine.DNS
alias Elektrine.DNS.Dnssec.Signer
alias Elektrine.DNS.Packet alias Elektrine.DNS.Packet
@type_names %{ @type_names %{
@ -17,6 +19,7 @@ defmodule Elektrine.DNS.Query do
"https" => :https, "https" => :https,
"mx" => :mx, "mx" => :mx,
"ns" => :ns, "ns" => :ns,
"rrsig" => :rrsig,
"soa" => :soa, "soa" => :soa,
"srv" => :srv, "srv" => :srv,
"sshfp" => :sshfp, "sshfp" => :sshfp,
@ -163,6 +166,9 @@ defmodule Elektrine.DNS.Query do
authority = [Elektrine.DNS.Zone.soa_record(zone)] authority = [Elektrine.DNS.Zone.soa_record(zone)]
additional = additional_records(zone, records, opts) additional = additional_records(zone, records, opts)
{records, authority, additional} =
maybe_attach_dnssec(zone, query, records, authority, additional)
cond do cond do
records != [] -> records != [] ->
response = response =
@ -196,6 +202,99 @@ defmodule Elektrine.DNS.Query do
end end
end end
# DNSSEC serve path (PR 10b). Flag off / unprepared zones are a pure no-op so
# unsigned authority behavior is unchanged. Failures never raise — unsigned
# types simply omit RRSIGs.
defp maybe_attach_dnssec(zone, query, records, authority, additional) do
if serve_dnssec?(zone) do
records = attach_answer_dnssec(zone, query, records)
authority = attach_authority_dnssec(zone, query, records, authority)
{records, authority, additional}
else
{records, authority, additional}
end
rescue
error ->
Logger.warning("DNSSEC attach failed for #{query.qname}: #{Exception.message(error)}")
{records, authority, additional}
end
defp serve_dnssec?(zone) do
DNS.dnssec_enabled?() and zone.dnssec_enabled == true and Signer.prepared?(zone)
end
defp attach_answer_dnssec(zone, query, records) do
cond do
records == [] ->
records
# DNSKEY at the apex is synthesized from key material, not user records.
query.qtype == :dnskey and apex_query?(zone, query.qname) ->
dnskeys = Signer.dnskey_records(zone)
if dnskeys == [] do
records
else
maybe_append_rrsigs(zone, query, dnskeys, normalize_name(zone.domain), :dnskey)
end
query.qtype == :rrsig ->
# Direct RRSIG queries: return covering signatures for the owner when known.
owner = normalize_name(query.qname)
zone
|> Map.get(:dnssec_bundle, %{})
|> Map.get(:rrsigs, %{})
|> Enum.filter(fn {{name, _type}, _} -> name == owner end)
|> Enum.flat_map(fn {_, sigs} -> sigs end)
true ->
type = answer_rrset_type(records, query.qtype)
owner = answer_rrset_owner(records, query.qname)
maybe_append_rrsigs(zone, query, records, owner, type)
end
end
defp attach_authority_dnssec(zone, query, records, authority) do
# Attach SOA RRSIG on NODATA / NXDOMAIN when the client set DO.
if records == [] and dnssec_ok?(query) do
soa_owner = normalize_name(zone.domain)
maybe_append_rrsigs(zone, query, authority, soa_owner, :soa)
else
authority
end
end
defp maybe_append_rrsigs(zone, query, records, owner, type) do
# RRSIGs accompany data only when the client set DO (RFC 4035), or when
# RRSIG itself is the QTYPE. Only attach when the answer RRset still matches
# what was signed (e.g. health failover must not keep a full-set RRSIG).
if dnssec_ok?(query) or query.qtype == :rrsig do
case Signer.rrsigs_for_matching(zone, owner, type, records) do
[] -> records
sigs -> records ++ sigs
end
else
records
end
end
defp dnssec_ok?(query), do: Map.get(query, :dnssec_ok, false) == true
defp apex_query?(zone, qname), do: normalize_name(qname) == normalize_name(zone.domain)
defp answer_rrset_type([first | _], _qtype) do
normalize_type(Map.get(first, :type))
end
defp answer_rrset_type(_, qtype), do: qtype
defp answer_rrset_owner([first | _], qname) do
Map.get(first, :host) || Map.get(first, :name) || qname
end
defp answer_rrset_owner(_, qname), do: qname
defp response_meta(query, response, zone, rcode, authoritative) do defp response_meta(query, response, zone, rcode, authoritative) do
%{ %{
response: response, response: response,
@ -249,9 +348,22 @@ defmodule Elektrine.DNS.Query do
defp do_records_for_query(zone, qname, qtype, opts) do defp do_records_for_query(zone, qname, qtype, opts) do
fqdn = normalize_name(qname) fqdn = normalize_name(qname)
if qtype == :soa and fqdn == normalize_name(zone.domain) do cond do
qtype == :soa and fqdn == normalize_name(zone.domain) ->
[Elektrine.DNS.Zone.soa_record(zone)] [Elektrine.DNS.Zone.soa_record(zone)]
else
qtype == :dnskey and fqdn == normalize_name(zone.domain) and serve_dnssec?(zone) ->
case Signer.dnskey_records(zone) do
[] -> exact_or_fallthrough(zone, fqdn, qtype, opts)
dnskeys -> dnskeys
end
true ->
exact_or_fallthrough(zone, fqdn, qtype, opts)
end
end
defp exact_or_fallthrough(zone, fqdn, qtype, opts) do
exact_records = exact_records_for_query(zone, fqdn, qtype, opts) exact_records = exact_records_for_query(zone, fqdn, qtype, opts)
if exact_records != [] do if exact_records != [] do
@ -280,7 +392,6 @@ defmodule Elektrine.DNS.Query do
end end
end end
end end
end
defp exact_records_for_query(zone, fqdn, qtype, opts) do defp exact_records_for_query(zone, fqdn, qtype, opts) do
zone_records(zone, opts) zone_records(zone, opts)

View file

@ -30,6 +30,8 @@ field :axfr_enabled, :boolean, default: false
field :axfr_allow_cidrs, {:array, :string}, default: [] field :axfr_allow_cidrs, {:array, :string}, default: []
field :axfr_require_tsig, :boolean, default: false field :axfr_require_tsig, :boolean, default: false
field :axfr_allow_cidrs_text, :string, virtual: true field :axfr_allow_cidrs_text, :string, virtual: true
# Populated by ZoneCache / Dnssec.Signer when DNS_DNSSEC_ENABLED and zone keys exist.
field :dnssec_bundle, :map, virtual: true, default: nil
belongs_to :user, Elektrine.Accounts.User belongs_to :user, Elektrine.Accounts.User
belongs_to :axfr_tsig_key, Elektrine.DNS.TsigKey belongs_to :axfr_tsig_key, Elektrine.DNS.TsigKey
@ -43,7 +45,7 @@ has_many :tunnels, Elektrine.DNS.Tunnel, foreign_key: :zone_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@dnssec_statuses ~w(disabled keys_generated) @dnssec_statuses ~w(disabled keys_generated signed)
def changeset(zone, attrs) do def changeset(zone, attrs) do
zone zone

View file

@ -1,6 +1,10 @@
defmodule Elektrine.DNS.ZoneCache do defmodule Elektrine.DNS.ZoneCache do
@moduledoc """ @moduledoc """
ETS-backed cache for authoritative zone data. ETS-backed cache for authoritative zone data.
When the global `DNS_DNSSEC_ENABLED` flag is on, verified zones with
`dnssec_enabled` are pre-signed on load/refresh (sign-on-publish) so the
query path can serve DNSKEY/RRSIG without crypto on the hot path.
""" """
use GenServer use GenServer
@ -8,6 +12,8 @@ defmodule Elektrine.DNS.ZoneCache do
require Logger require Logger
alias Elektrine.DNS
alias Elektrine.DNS.Dnssec.Signer
alias Elektrine.DNS.Zone alias Elektrine.DNS.Zone
alias Elektrine.Repo alias Elektrine.Repo
@ -97,17 +103,42 @@ defmodule Elektrine.DNS.ZoneCache do
end end
defp load_zones(repo_opts) do defp load_zones(repo_opts) do
{:ok, sign_dnssec? = DNS.dnssec_enabled?()
zones =
Zone Zone
|> where([z], z.status == "verified") |> where([z], z.status == "verified")
|> preload(:records) |> preload_zone_assocs(sign_dnssec?)
|> Repo.all(repo_opts) |> Repo.all(repo_opts)
|> Enum.map(&repair_builtin_zone_records/1)} |> Enum.map(&repair_builtin_zone_records/1)
|> Enum.map(&maybe_prepare_dnssec(&1, sign_dnssec?))
{:ok, zones}
rescue rescue
error in [Postgrex.Error, DBConnection.OwnershipError] -> error in [Postgrex.Error, DBConnection.OwnershipError] ->
{:error, error} {:error, error}
end end
defp preload_zone_assocs(query, true) do
preload(query, [:records, :dnssec_keys])
end
defp preload_zone_assocs(query, false) do
preload(query, :records)
end
defp maybe_prepare_dnssec(%Zone{} = zone, true) do
if zone.dnssec_enabled == true do
# prepare_zone strips dnssec_keys so PEMs never land in ETS.
Signer.prepare_zone(zone)
else
# Do not retain preloaded key rows when the zone is not DNSSEC-enabled.
%{zone | dnssec_keys: []}
end
end
defp maybe_prepare_dnssec(zone, _), do: zone
defp repair_builtin_zone_records(%Zone{} = zone) do defp repair_builtin_zone_records(%Zone{} = zone) do
case Elektrine.DNS.repair_builtin_user_zone_records(zone) do case Elektrine.DNS.repair_builtin_user_zone_records(zone) do
{:ok, zone} -> zone {:ok, zone} -> zone

View file

@ -0,0 +1,372 @@
defmodule Elektrine.DNS.DnssecQueryTest do
use Elektrine.DataCase, async: false
import Bitwise
alias Elektrine.AccountsFixtures
alias Elektrine.DNS
alias Elektrine.DNS.Dnssec
alias Elektrine.DNS.Dnssec.Signer
alias Elektrine.DNS.Packet
alias Elektrine.DNS.Query
alias Elektrine.DNS.Record
setup do
old_master = Application.get_env(:elektrine, :encryption_master_secret)
old_salt = Application.get_env(:elektrine, :encryption_key_salt)
old_dns = Application.get_env(:elektrine, :dns, [])
Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-dnssec-012345")
Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-dnssec-0123456789")
Application.put_env(
:elektrine,
:dns,
Keyword.merge(old_dns,
dnssec_enabled: false,
nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"],
edge_proxy_ipv4_addresses: ["198.51.100.200"],
edge_proxy_ipv6_addresses: []
)
)
on_exit(fn ->
restore_env(:encryption_master_secret, old_master)
restore_env(:encryption_key_salt, old_salt)
Application.put_env(:elektrine, :dns, old_dns)
end)
table = :ets.whereis(Elektrine.DNS.ZoneCache)
if table == :undefined do
:ets.new(Elektrine.DNS.ZoneCache, [:named_table, :public])
else
:ets.delete_all_objects(Elektrine.DNS.ZoneCache)
end
user = AccountsFixtures.user_fixture()
{:ok, zone} = DNS.create_zone(user, %{"domain" => "dnssec-q.example.com"})
{:ok, _} =
DNS.create_record(zone, %{
"name" => "@",
"type" => "A",
"content" => "203.0.113.77",
"ttl" => 300
})
{:ok, _} =
DNS.create_record(zone, %{
"name" => "www",
"type" => "A",
"content" => "203.0.113.78",
"ttl" => 300
})
{:ok, _} =
DNS.create_record(zone, %{
"name" => "txt",
"type" => "TXT",
"content" => "v=hello",
"ttl" => 300
})
zone = DNS.get_zone(zone.id, user.id)
%{user: user, zone: zone}
end
test "flag off: no RRSIGs even when zone has keys", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys, status: "verified"})
# Global flag remains false (setup default).
refute DNS.dnssec_enabled?()
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
response = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
assert header(response).ancount == 1
assert header(response).rcode == 0
assert response =~ <<203, 0, 113, 77>>
refute has_rrsig?(response)
dnskey = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
# Without the serve flag, user/managed DNSKEY path is empty for this zone.
assert header(dnskey).ancount == 0
end
test "flag on + enabled zone: serves DNSKEY and RRSIG for signed types", %{zone: zone} do
put_dnssec_flag(true)
assert DNS.dnssec_enabled?()
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
prepared =
Signer.prepare_zone(%{
enabled
| dnssec_keys: keys,
status: "verified",
records: zone.records
})
assert Signer.prepared?(prepared)
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
# A + DO → answer + RRSIG
a_response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
assert header(a_response).rcode == 0
assert header(a_response).ancount == 2
assert a_response =~ <<203, 0, 113, 78>>
assert has_rrsig?(a_response)
# A without DO → no RRSIG
plain = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: false))
assert header(plain).ancount == 1
refute has_rrsig?(plain)
# Apex DNSKEY → both keys (+ RRSIG when DO)
dnskey_resp = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
assert header(dnskey_resp).rcode == 0
# 2 DNSKEY + 1 RRSIG
assert header(dnskey_resp).ancount == 3
assert has_rrsig?(dnskey_resp)
dnskey_plain = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: false))
assert header(dnskey_plain).ancount == 2
refute has_rrsig?(dnskey_plain)
# SOA + DO includes RRSIG
soa = Query.answer(build_query("dnssec-q.example.com", 6, dnssec_ok: true))
assert header(soa).ancount == 2
assert has_rrsig?(soa)
# TXT signed
txt = Query.answer(build_query("txt.dnssec-q.example.com", 16, dnssec_ok: true))
assert header(txt).ancount == 2
assert has_rrsig?(txt)
end
test "unsigned types / missing signatures do not crash", %{zone: zone} do
put_dnssec_flag(true)
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
# Bundle with DNSKEY only — no A RRSIGs — still answers A without crash.
prepared = %{
enabled
| dnssec_keys: [],
status: "verified",
records: zone.records,
dnssec_bundle: %{
dnskeys: Signer.dnskey_records(Signer.prepare_zone(%{enabled | dnssec_keys: keys})),
rrsigs: %{},
rrset_digests: %{},
signed_at: DateTime.utc_now()
}
}
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
assert header(response).rcode == 0
assert header(response).ancount == 1
assert response =~ <<203, 0, 113, 78>>
refute has_rrsig?(response)
end
test "zone without dnssec_enabled never serves signatures even with flag on", %{zone: zone} do
put_dnssec_flag(true)
unsigned = %{zone | dnssec_enabled: false, status: "verified"}
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", unsigned})
response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
assert header(response).ancount == 1
refute has_rrsig?(response)
dnskey = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
assert header(dnskey).ancount == 0
end
test "NODATA authority can carry SOA RRSIG when DO is set", %{zone: zone} do
put_dnssec_flag(true)
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
prepared =
Signer.prepare_zone(%{
enabled
| dnssec_keys: keys,
status: "verified",
records: zone.records
})
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
# www has A but no TXT → NODATA with SOA + SOA RRSIG in authority
response = Query.answer(build_query("www.dnssec-q.example.com", 16, dnssec_ok: true))
assert header(response).rcode == 0
assert header(response).ancount == 0
assert header(response).nscount == 2
assert has_rrsig?(response)
end
test "health failover omits RRSIG when answer RRset no longer matches signed set", %{
zone: zone
} do
put_dnssec_flag(true)
if :ets.whereis(:elektrine_dns_health_status) == :undefined do
:ets.new(:elektrine_dns_health_status, [:named_table, :set, :public])
end
on_exit(fn ->
if :ets.whereis(:elektrine_dns_health_status) != :undefined do
:ets.delete(:elektrine_dns_health_status, {"203.0.113.30", 443})
:ets.delete(:elektrine_dns_health_status, {"203.0.113.31", 443})
end
end)
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
records = [
%Record{
name: "@",
type: "A",
content: "203.0.113.30",
ttl: 300,
metadata: %{"health_check" => %{"enabled" => true}}
},
%Record{
name: "@",
type: "A",
content: "203.0.113.31",
ttl: 300,
metadata: %{"health_check" => %{"enabled" => true}}
}
]
prepared =
Signer.prepare_zone(%{
enabled
| dnssec_keys: keys,
status: "verified",
records: records
})
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
# Full set healthy → both A + covering RRSIG
full = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
assert header(full).ancount == 3
assert has_rrsig?(full)
assert full =~ <<203, 0, 113, 30>>
assert full =~ <<203, 0, 113, 31>>
# One target down → single A without RRSIG (never full-set RRSIG on subset)
:ets.insert(:elektrine_dns_health_status, {{"203.0.113.31", 443}, :down, 3})
filtered = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
assert header(filtered).ancount == 1
assert filtered =~ <<203, 0, 113, 30>>
refute filtered =~ <<203, 0, 113, 31>>
refute has_rrsig?(filtered)
end
test "Packet response with RRSIG survives encode_response" do
query = %{
id: 42,
rd: 1,
qname: "example.com",
qtype: :a,
udp_size: 1232,
dnssec_ok: true,
edns: true
}
answers = [
%{host: "example.com", type: :a, content: "192.0.2.1", ttl: 300},
%{
host: "example.com",
type: :rrsig,
ttl: 300,
type_covered: :a,
algorithm: 13,
labels: 2,
original_ttl: 300,
expiration: 1_800_000_000,
inception: 1_700_000_000,
key_tag: 1001,
signer: "example.com",
signature: :binary.copy(<<1>>, 64)
}
]
response = Packet.encode_response(query, answers, :noerror, authoritative: true)
assert header(response).ancount == 2
assert has_rrsig?(response)
assert header(response).aa == 1
end
test "regression: ordinary authoritative answers still work with flag on", %{zone: zone} do
put_dnssec_flag(true)
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", %{zone | status: "verified"}})
response = Query.answer(build_query("www.dnssec-q.example.com", 1))
assert header(response).rcode == 0
assert header(response).ancount == 1
assert response =~ <<203, 0, 113, 78>>
refute has_rrsig?(response)
end
defp put_dnssec_flag(enabled) do
dns = Application.get_env(:elektrine, :dns, [])
Application.put_env(:elektrine, :dns, Keyword.put(dns, :dnssec_enabled, enabled))
end
defp build_query(name, type, opts \\ []) do
Packet.encode_query(%{
id: 0x1234,
rd: 1,
qname: name,
qtype: decode_qtype(type),
udp_size: 1232,
dnssec_ok: Keyword.get(opts, :dnssec_ok, false)
})
end
defp decode_qtype(type) when is_integer(type) do
%{
1 => :a,
6 => :soa,
16 => :txt,
28 => :aaaa,
46 => :rrsig,
48 => :dnskey
}
|> Map.get(type, type)
end
defp decode_qtype(type), do: type
defp has_rrsig?(packet) when is_binary(packet) do
# RRSIG type number is 46 (0x002E). Look for TYPE=46 CLASS=IN in answers.
:binary.match(packet, <<46::16, 1::16>>) != :nomatch
end
defp header(
<<_id::16, flags::16, _qd::16, ancount::16, nscount::16, arcount::16, _rest::binary>>
) do
%{
rcode: flags &&& 0x000F,
aa: flags >>> 10 &&& 1,
ancount: ancount,
nscount: nscount,
arcount: arcount
}
end
defp restore_env(key, nil), do: Application.delete_env(:elektrine, key)
defp restore_env(key, value), do: Application.put_env(:elektrine, key, value)
end

View file

@ -0,0 +1,192 @@
defmodule Elektrine.DNS.Dnssec.SignerTest do
use Elektrine.DataCase, async: false
alias Elektrine.AccountsFixtures
alias Elektrine.DNS
alias Elektrine.DNS.Dnssec
alias Elektrine.DNS.Dnssec.Signer
alias Elektrine.DNS.Packet
alias Elektrine.DNS.Record
setup do
old_master = Application.get_env(:elektrine, :encryption_master_secret)
old_salt = Application.get_env(:elektrine, :encryption_key_salt)
old_dns = Application.get_env(:elektrine, :dns, [])
Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-dnssec-012345")
Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-dnssec-0123456789")
Application.put_env(
:elektrine,
:dns,
Keyword.merge(old_dns,
dnssec_enabled: true,
nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"]
)
)
on_exit(fn ->
restore_env(:encryption_master_secret, old_master)
restore_env(:encryption_key_salt, old_salt)
Application.put_env(:elektrine, :dns, old_dns)
end)
user = AccountsFixtures.user_fixture()
{:ok, zone} = DNS.create_zone(user, %{"domain" => "sign-example.com"})
{:ok, _record} =
DNS.create_record(zone, %{"name" => "@", "type" => "A", "content" => "203.0.113.9"})
zone = DNS.get_zone(zone.id, user.id)
%{user: user, zone: zone}
end
test "prepare_zone builds DNSKEY answers and verifiable RRSIGs", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys})
assert Signer.prepared?(prepared)
dnskeys = Signer.dnskey_records(prepared)
assert length(dnskeys) == 2
assert Enum.all?(dnskeys, &(&1.type == :dnskey and &1.host == "sign-example.com"))
a_records = [
%{host: "sign-example.com", type: :a, content: "203.0.113.9", ttl: 300}
]
[rrsig] = Signer.rrsigs_for(prepared, "sign-example.com", :a)
assert rrsig.type == :rrsig
assert rrsig.type_covered == :a
assert rrsig.algorithm == 13
assert rrsig.labels == 2
assert byte_size(rrsig.signature) == 64
zsk = Enum.find(keys, &(&1.key_role == "zsk"))
assert Signer.verify_rrsig(rrsig, a_records, zsk.public_key)
[dnskey_rrsig] = Signer.rrsigs_for(prepared, "sign-example.com", :dnskey)
ksk = Enum.find(keys, &(&1.key_role == "ksk"))
assert Signer.verify_rrsig(dnskey_rrsig, dnskeys, ksk.public_key)
end
test "sign_rrset produces verifiable ECDSA P-256 signatures", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
[zsk | _] = Enum.filter(Dnssec.list_active_keys(enabled), &(&1.key_role == "zsk"))
now = ~U[2026-01-15 12:00:00Z]
records = [%{host: "sign-example.com", type: :a, content: "192.0.2.1", ttl: 300}]
assert {:ok, sig1} =
Signer.sign_rrset("sign-example.com", :a, records, zsk,
now: now,
signer: "sign-example.com"
)
assert {:ok, sig2} =
Signer.sign_rrset("sign-example.com", :a, records, zsk,
now: now,
signer: "sign-example.com"
)
# ECDSA nonces are random; both signatures must verify the same RRset.
assert sig1.inception == sig2.inception
assert sig1.expiration == sig2.expiration
assert byte_size(sig1.signature) == 64
assert byte_size(sig2.signature) == 64
assert Signer.verify_rrsig(sig1, records, zsk.public_key)
assert Signer.verify_rrsig(sig2, records, zsk.public_key)
end
test "prepare_zone is a no-op without keys", %{zone: zone} do
prepared = Signer.prepare_zone(zone)
refute Signer.prepared?(prepared)
assert prepared.dnssec_bundle == nil
assert prepared.dnssec_keys == []
end
test "prepare_zone drops private keys from the returned zone", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
assert Enum.any?(keys, &is_binary(&1.private_key))
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys})
assert Signer.prepared?(prepared)
assert prepared.dnssec_keys == []
end
test "covers_rrset? rejects reduced answer sets (health-failover shape)", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
multi = %{
enabled
| dnssec_keys: keys,
records: [
%Record{name: "@", type: "A", content: "203.0.113.30", ttl: 300},
%Record{name: "@", type: "A", content: "203.0.113.31", ttl: 300}
]
}
prepared = Signer.prepare_zone(multi)
full = [
%{host: "sign-example.com", type: :a, content: "203.0.113.30", ttl: 300},
%{host: "sign-example.com", type: :a, content: "203.0.113.31", ttl: 300}
]
one = [%{host: "sign-example.com", type: :a, content: "203.0.113.30", ttl: 300}]
assert Signer.covers_rrset?(prepared, "sign-example.com", :a, full)
refute Signer.covers_rrset?(prepared, "sign-example.com", :a, one)
assert Signer.rrsigs_for_matching(prepared, "sign-example.com", :a, full) != []
assert Signer.rrsigs_for_matching(prepared, "sign-example.com", :a, one) == []
end
test "unsigned / unencodable types do not crash prepare", %{zone: zone} do
assert {:ok, enabled} = Dnssec.enable_zone(zone)
keys = Dnssec.list_active_keys(enabled)
weird = %{
enabled
| dnssec_keys: keys,
records: [
%Record{name: "@", type: "A", content: "203.0.113.1", ttl: 300},
%Record{name: "bad", type: "A", content: "not-an-ip", ttl: 300}
]
}
prepared = Signer.prepare_zone(weird)
assert Signer.prepared?(prepared)
# Good A RRset still signed; bad A skipped without raising.
assert Signer.rrsigs_for(prepared, "sign-example.com", :a) != []
end
test "Packet encodes RRSIG rdata for algorithm 13" do
rrsig = %{
host: "example.com",
type: :rrsig,
ttl: 300,
type_covered: :a,
algorithm: 13,
labels: 2,
original_ttl: 300,
expiration: 1_800_000_000,
inception: 1_700_000_000,
key_tag: 12_345,
signer: "example.com",
signature: :binary.copy(<<0xAB>>, 64)
}
rdata = Packet.rr_rdata(rrsig)
# type covered A (1), alg 13, labels 2, original ttl 300
assert <<1::16, 13, 2, 300::32, 1_800_000_000::32, 1_700_000_000::32, 12_345::16,
rest::binary>> = rdata
assert rest =~ <<7, "example", 3, "com", 0>>
assert String.ends_with?(rest, :binary.copy(<<0xAB>>, 64))
end
defp restore_env(key, nil), do: Application.delete_env(:elektrine, key)
defp restore_env(key, value), do: Application.put_env(:elektrine, key, value)
end

View file

@ -304,6 +304,8 @@ config :elektrine, :dns,
authority_enabled: false, authority_enabled: false,
recursive_enabled: false, recursive_enabled: false,
public_bind_ip: nil, public_bind_ip: nil,
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (sign-on-publish). Default off.
dnssec_enabled: false,
zone_cache_refresh_interval_ms: 300_000, zone_cache_refresh_interval_ms: 300_000,
udp_port: 5300, udp_port: 5300,
tcp_port: 5300, tcp_port: 5300,

View file

@ -187,6 +187,11 @@ config :elektrine, :dns,
"DNS_RECURSIVE_ENABLED", "DNS_RECURSIVE_ENABLED",
Keyword.get(dns_config, :recursive_enabled, false) Keyword.get(dns_config, :recursive_enabled, false)
), ),
dnssec_enabled:
parse_bool_env.(
"DNS_DNSSEC_ENABLED",
Keyword.get(dns_config, :dnssec_enabled, false)
),
edge_proxy_enabled: edge_proxy_enabled:
parse_bool_env.( parse_bool_env.(
"DNS_EDGE_PROXY_ENABLED", "DNS_EDGE_PROXY_ENABLED",

View file

@ -123,6 +123,8 @@ MESSAGING_FEDERATION_PEERS_JSON='[]'
# INTERNAL_API_KEY= # INTERNAL_API_KEY=
# DNS_RECURSIVE_ENABLED=true # DNS_RECURSIVE_ENABLED=true
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (default off).
# DNS_DNSSEC_ENABLED=false
# VPN_SELFHOST_PUBLIC_IP=203.0.113.10 # VPN_SELFHOST_PUBLIC_IP=203.0.113.10
# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com # VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com
# VPN_SELFHOST_PRIVATE_KEY= # VPN_SELFHOST_PRIVATE_KEY=